From 57528ffbdd94bf04ce902c67c7f99515b6ce13fa Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 18 Aug 2021 15:40:01 +0200 Subject: [PATCH 001/238] Add fallback for parsing album audioPlaylistId (#219, closes #220) --- ytmusicapi/parsers/albums.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ytmusicapi/parsers/albums.py b/ytmusicapi/parsers/albums.py index 5d2aed15..84c35cfc 100644 --- a/ytmusicapi/parsers/albums.py +++ b/ytmusicapi/parsers/albums.py @@ -25,7 +25,9 @@ def parse_album_header(response): # add to library/uploaded menu = nav(header, MENU) toplevel = menu['topLevelButtons'] - album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID) + album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID, True) + if not album['audioPlaylistId']: + album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_PLAYLIST_ID, True) service = nav(toplevel, [1, 'buttonRenderer', 'defaultServiceEndpoint'], True) if service: album['likeStatus'] = parse_like_status(service) From 05812a9e70807f2d34516b200d4d6a5a69a756bf Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 18 Aug 2021 16:15:17 +0200 Subject: [PATCH 002/238] Update version to 0.19.1 --- ytmusicapi/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/_version.py b/ytmusicapi/_version.py index 11ac8e1a..4c1ca3c8 100644 --- a/ytmusicapi/_version.py +++ b/ytmusicapi/_version.py @@ -1 +1 @@ -__version__ = "0.19.0" +__version__ = "0.19.1" From 9d9d747b72da49bf2be02b50f45ab26841ec37bd Mon Sep 17 00:00:00 2001 From: Dan Herd <659658+danherd@users.noreply.github.com> Date: Fri, 20 Aug 2021 08:56:06 +0100 Subject: [PATCH 003/238] Fix error in docs --- docs/source/setup.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/setup.rst b/docs/source/setup.rst index 9a02dafe..44a4a331 100644 --- a/docs/source/setup.rst +++ b/docs/source/setup.rst @@ -58,14 +58,14 @@ To set up your project, open a Python console and call :py:func:`YTMusic.setup` .. code-block:: python from ytmusicapi import YTMusic - YTMusic.setup(filepath=headers_auth.json) + YTMusic.setup(filepath="headers_auth.json") If you don't want terminal interaction in your project, you can pass the request headers with the ``headers_raw`` parameter: .. code-block:: python from ytmusicapi import YTMusic - YTMusic.setup(filepath=headers_auth.json, headers_raw="") + YTMusic.setup(filepath="headers_auth.json", headers_raw="") The function returns a JSON string with the credentials needed for :doc:`Usage `. Alternatively, if you passed the filepath parameter as described above, a file called ``headers_auth.json`` will be created in the current directory, which you can pass to ``YTMusic()`` for authentication. From 013a1bfa8ad11989d02b9c09fa4b4098bed07b89 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 26 Aug 2021 20:33:10 +0200 Subject: [PATCH 004/238] remove pkg_resources (closes #223, #224) --- ytmusicapi/ytmusic.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 8b587d43..641baf0c 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -1,6 +1,5 @@ import requests import gettext -import pkg_resources import os from contextlib import suppress from typing import Dict @@ -90,7 +89,8 @@ def __init__(self, # prepare context self.context = initialize_context() self.context['context']['client']['hl'] = language - supported_languages = [f for f in pkg_resources.resource_listdir('ytmusicapi', 'locales')] + locale_dir = os.path.abspath(os.path.dirname(__file__)) + os.sep + 'locales' + supported_languages = [f for f in os.listdir(locale_dir)] if language not in supported_languages: raise Exception("Language not supported. Supported languages are " ', '.join(supported_languages)) @@ -101,8 +101,7 @@ def __init__(self, with suppress(locale.Error): locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') self.lang = gettext.translation('base', - localedir=pkg_resources.resource_filename( - 'ytmusicapi', 'locales'), + localedir=locale_dir, languages=[language]) self.parser = browsing.Parser(self.lang) From 3aaeaa28f7fb7b13f7ab01c1813a030382c013a0 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 26 Aug 2021 20:34:02 +0200 Subject: [PATCH 005/238] get_charts: fix issue with unavailable entries --- ytmusicapi/parsers/explore.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/parsers/explore.py b/ytmusicapi/parsers/explore.py index 9fdc34ce..2b648540 100644 --- a/ytmusicapi/parsers/explore.py +++ b/ytmusicapi/parsers/explore.py @@ -7,7 +7,7 @@ def parse_chart_song(data): flex_0 = get_flex_column_item(data, 0) parsed = { 'title': nav(flex_0, TEXT_RUN_TEXT), - 'videoId': nav(flex_0, TEXT_RUN + NAVIGATION_VIDEO_ID), + 'videoId': nav(flex_0, TEXT_RUN + NAVIGATION_VIDEO_ID, True), 'artists': parse_song_artists(data, 1), 'thumbnails': nav(data, THUMBNAILS), 'isExplicit': nav(data, BADGE_LABEL, True) == 'Explicit' From 0aa284e860e5091684d4c193a22f354589ed9262 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 26 Aug 2021 22:51:16 +0200 Subject: [PATCH 006/238] get_charts: fix views, add video artists --- ytmusicapi/parsers/browsing.py | 5 +++-- ytmusicapi/parsers/explore.py | 2 +- ytmusicapi/parsers/songs.py | 18 +++++++++++------- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 76919a65..5cb081f9 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -179,14 +179,15 @@ def parse_single(result): def parse_video(result): + runs = result['subtitle']['runs'] video = { 'title': nav(result, TITLE_TEXT), 'videoId': nav(result, NAVIGATION_VIDEO_ID), + 'artists': parse_song_artists_runs(runs[:-2]), 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), 'thumbnails': nav(result, THUMBNAIL_RENDERER, True) } - if len(result['subtitle']['runs']) == 3: - video['views'] = nav(result, SUBTITLE2).split(' ')[0] + video['views'] = runs[-1]['text'].split(' ')[0] return video diff --git a/ytmusicapi/parsers/explore.py b/ytmusicapi/parsers/explore.py index 2b648540..5abbd362 100644 --- a/ytmusicapi/parsers/explore.py +++ b/ytmusicapi/parsers/explore.py @@ -20,7 +20,7 @@ def parse_chart_song(data): } else: flex_1 = get_flex_column_item(data, 1) - parsed['views'] = nav(flex_1, ['text', 'runs', 2, 'text']).split(' ')[0] + parsed['views'] = nav(flex_1, ['text', 'runs', -1, 'text']).split(' ')[0] parsed.update(parse_ranking(data)) return parsed diff --git a/ytmusicapi/parsers/songs.py b/ytmusicapi/parsers/songs.py index a5dcd827..60bbdded 100644 --- a/ytmusicapi/parsers/songs.py +++ b/ytmusicapi/parsers/songs.py @@ -8,13 +8,17 @@ def parse_song_artists(data, index): return None else: runs = flex_item['text']['runs'] - artists = [] - for j in range(int(len(runs) / 2) + 1): - artists.append({ - 'name': runs[j * 2]['text'], - 'id': nav(runs[j * 2], NAVIGATION_BROWSE_ID, True) - }) - return artists + return parse_song_artists_runs(runs) + + +def parse_song_artists_runs(runs): + artists = [] + for j in range(int(len(runs) / 2) + 1): + artists.append({ + 'name': runs[j * 2]['text'], + 'id': nav(runs[j * 2], NAVIGATION_BROWSE_ID, True) + }) + return artists def parse_song_runs(runs): From 945b0215117fcf6f4e38d674b4772a3d879e1922 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 8 Sep 2021 10:45:03 +0200 Subject: [PATCH 007/238] Change Python requirement to 3.6 (closes #225) --- README.rst | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 069d9374..3d9cec0a 100644 --- a/README.rst +++ b/README.rst @@ -79,7 +79,7 @@ The `tests `_ a Requirements ============== -- Python 3.5 or higher - https://www.python.org +- Python 3.6 or higher - https://www.python.org Setup and Usage =============== diff --git a/setup.py b/setup.py index 8dcb68f6..6ab222b2 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,6 @@ extras_require={ 'dev': ['pre-commit', 'flake8', 'yapf', 'coverage', 'sphinx', 'sphinx-rtd-theme'] }, - python_requires=">=3.5", + python_requires=">=3.6", include_package_data=True, zip_safe=False) From fd9f57750de103202106f02be1696bd440f2c05b Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 8 Sep 2021 15:22:47 +0200 Subject: [PATCH 008/238] Update version to 0.19.2 --- ytmusicapi/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/_version.py b/ytmusicapi/_version.py index 4c1ca3c8..aa070c2c 100644 --- a/ytmusicapi/_version.py +++ b/ytmusicapi/_version.py @@ -1 +1 @@ -__version__ = "0.19.1" +__version__ = "0.19.2" From 35ad412cfe775f08f409d4fa3ab16a8623f60878 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Fri, 24 Sep 2021 21:33:57 +0200 Subject: [PATCH 009/238] fix parsing bug for default search (no filter, no scope) introduced in 0.19.0 (closes #227) --- ytmusicapi/mixins/browsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index cff83f0a..ade7183a 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -160,7 +160,7 @@ def search(self, if 'musicShelfRenderer' in res: results = res['musicShelfRenderer']['contents'] original_filter = filter - if not filter: + if not filter and scope == scopes[0]: filter = nav(res, MUSIC_SHELF + TITLE_TEXT, True) type = filter[:-1].lower() if filter else None From 7a73d015dbbd98cf845a9b2bd8f4ef8529039c25 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Fri, 24 Sep 2021 21:36:07 +0200 Subject: [PATCH 010/238] add get_library_subscriptions to docs (closes #226) --- docs/source/reference.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/source/reference.rst b/docs/source/reference.rst index befcd7dd..3c85fd7e 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -45,8 +45,9 @@ Library ------- .. automethod:: YTMusic.get_library_playlists .. automethod:: YTMusic.get_library_songs -.. automethod:: YTMusic.get_library_artists .. automethod:: YTMusic.get_library_albums +.. automethod:: YTMusic.get_library_artists +.. automethod:: YTMusic.get_library_subscriptions .. automethod:: YTMusic.get_liked_songs .. automethod:: YTMusic.get_history .. automethod:: YTMusic.remove_history_items From 7ddfb3da49596fde98c8f33d4287acc57de19d6f Mon Sep 17 00:00:00 2001 From: sigma67 Date: Fri, 24 Sep 2021 21:37:13 +0200 Subject: [PATCH 011/238] Update version to 0.19.3 --- ytmusicapi/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/_version.py b/ytmusicapi/_version.py index aa070c2c..1a95d562 100644 --- a/ytmusicapi/_version.py +++ b/ytmusicapi/_version.py @@ -1 +1 @@ -__version__ = "0.19.2" +__version__ = "0.19.3" From c536720e6ea7b0da3190f685624772ba8133ff7f Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 3 Oct 2021 13:25:50 +0200 Subject: [PATCH 012/238] add category property for search results (closes #229) --- ytmusicapi/mixins/browsing.py | 13 ++++++++++--- ytmusicapi/parsers/browsing.py | 4 ++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index ade7183a..80ebacb8 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -38,6 +38,7 @@ def search(self, [ { + "category": "Top result", "resultType": "video", "videoId": "vU05Eksc_iM", "title": "Wonderwall", @@ -51,6 +52,7 @@ def search(self, "duration": "4:38" }, { + "category": "Songs", "resultType": "song", "videoId": "ZrOKjDZOtkA", "title": "Wonderwall", @@ -72,6 +74,7 @@ def search(self, } }, { + "category": "Albums", "resultType": "album", "browseId": "MPREb_9nqEki4ZDpp", "title": "(What's The Story) Morning Glory? (Remastered)", @@ -81,6 +84,7 @@ def search(self, "isExplicit": false }, { + "category": "Community playlists", "resultType": "playlist", "browseId": "VLPLK1PkWQlWtnNfovRdGWpKffO1Wdi2kvDx", "title": "Wonderwall - Oasis", @@ -88,6 +92,7 @@ def search(self, "itemCount": "174" }, { + "category": "Videos", "resultType": "video", "videoId": "bx1Bh8ZvH84", "title": "Wonderwall", @@ -101,6 +106,7 @@ def search(self, "duration": "4:38" }, { + "category": "Artists", "resultType": "artist", "browseId": "UCmMUZbaYdNH0bEd1PAlAqsA", "artist": "Oasis", @@ -160,18 +166,19 @@ def search(self, if 'musicShelfRenderer' in res: results = res['musicShelfRenderer']['contents'] original_filter = filter + category = nav(res, MUSIC_SHELF + TITLE_TEXT, True) if not filter and scope == scopes[0]: - filter = nav(res, MUSIC_SHELF + TITLE_TEXT, True) + filter = category type = filter[:-1].lower() if filter else None - search_results.extend(self.parser.parse_search_results(results, type)) + search_results.extend(self.parser.parse_search_results(results, type, category)) filter = original_filter if 'continuations' in res['musicShelfRenderer']: request_func = lambda additionalParams: self._send_request( endpoint, body, additionalParams) - parse_func = lambda contents: self.parser.parse_search_results(contents, type) + parse_func = lambda contents: self.parser.parse_search_results(contents, type, category) search_results.extend( get_continuations(res['musicShelfRenderer'], 'musicShelfContinuation', diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 5cb081f9..044447cf 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -9,12 +9,12 @@ def __init__(self, language): self.lang = language @i18n - def parse_search_results(self, results, resultType=None): + def parse_search_results(self, results, resultType=None, category=None): search_results = [] default_offset = (not resultType) * 2 for result in results: data = result[MRLIR] - search_result = {} + search_result = {'category': category} if not resultType: resultType = get_item_text(data, 1).lower() result_types = ['artist', 'playlist', 'song', 'video', 'station'] From 3bc3ca67019038a7b86ebe53397fb1bf0aab5f53 Mon Sep 17 00:00:00 2001 From: Anandu Thampi <42245056+4ndu-7h4k@users.noreply.github.com> Date: Fri, 1 Oct 2021 23:06:43 +0530 Subject: [PATCH 013/238] fix in docs --- ytmusicapi/mixins/watch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index 2dea0368..95e49881 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -42,7 +42,7 @@ def get_watch_playlist(self, "likeStatus": "LIKE" },... ], - "playlist": "RDAMVM4y33h81phKU", + "playlistId": "RDAMVM4y33h81phKU", "lyrics": "MPLYt_HNNclO0Ddoc-17" } From 1f8e34cb311de3e870fa1c2e818d0c7e72ff0de6 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 3 Oct 2021 14:20:51 +0200 Subject: [PATCH 014/238] add default session timeout of 30s (closes #221) --- ytmusicapi/ytmusic.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 641baf0c..c5103ea7 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -1,6 +1,7 @@ import requests import gettext import os +from functools import partial from contextlib import suppress from typing import Dict from ytmusicapi.helpers import * @@ -40,6 +41,13 @@ def __init__(self, by going to https://myaccount.google.com/brandaccounts and selecting your brand account. The user ID will be in the URL: https://myaccount.google.com/b/user_id/ :param requests_session: A Requests session object or a truthy value to create one. + Default sessions have a request timeout of 30s, which produces a requests.exceptions.ReadTimeout. + The timeout can be changed by passing your own Session object:: + + s = requests.Session() + s.request = functools.partial(s.request, timeout=3) + ytm = YTMusic(session=s) + A falsy value disables sessions. It is generally a good idea to keep sessions enabled for performance reasons (connection pooling). @@ -59,6 +67,7 @@ def __init__(self, else: if requests_session: # Build a new session. self._session = requests.Session() + self._session.request = partial(self._session.request, timeout=30) else: # Use the Requests API module as a "session". self._session = requests.api From 72661fe1ff9567597a96907ef433340c4a0cb39b Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 28 Oct 2021 21:58:52 +0200 Subject: [PATCH 015/238] remove browse endpoint request keys that are no longer used --- ytmusicapi/helpers.py | 11 ----------- ytmusicapi/mixins/browsing.py | 4 ++-- ytmusicapi/mixins/playlists.py | 2 +- ytmusicapi/mixins/uploads.py | 4 ++-- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index 3d8a280d..b58bcf6c 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -9,17 +9,6 @@ from ytmusicapi.constants import * -def prepare_browse_endpoint(type, browseId): - return { - 'browseEndpointContextSupportedConfigs': { - "browseEndpointContextMusicConfig": { - "pageType": "MUSIC_PAGE_TYPE_" + type - } - }, - 'browseId': browseId - } - - def prepare_like_endpoint(rating): if rating == 'LIKE': return 'like/like' diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 80ebacb8..669e63c2 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -275,7 +275,7 @@ def get_artist(self, channelId: str) -> Dict: """ if channelId.startswith("MPLA"): channelId = channelId[4:] - body = prepare_browse_endpoint("ARTIST", channelId) + body = {'browseId': channelId} endpoint = 'browse' response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) @@ -461,7 +461,7 @@ def get_album(self, browseId: str) -> Dict: ] } """ - body = prepare_browse_endpoint("ALBUM", browseId) + body = {'browseId': browseId} endpoint = 'browse' response = self._send_request(endpoint, body) album = {} diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 0ec58b1b..ce60a237 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -61,7 +61,7 @@ def get_playlist(self, playlistId: str, limit: int = 100) -> Dict: needed for moving/removing playlist items """ browseId = "VL" + playlistId if not playlistId.startswith("VL") else playlistId - body = prepare_browse_endpoint("PLAYLIST", browseId) + body = {'browseId': browseId} endpoint = 'browse' response = self._send_request(endpoint, body) results = nav(response, diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index e8108225..835061ca 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -126,7 +126,7 @@ def get_library_upload_artist(self, browseId: str, limit: int = 25) -> List[Dict ] """ self._check_auth() - body = prepare_browse_endpoint("ARTIST", browseId) + body = {'browseId': browseId} endpoint = 'browse' response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) @@ -177,7 +177,7 @@ def get_library_upload_album(self, browseId: str) -> Dict: }, """ self._check_auth() - body = prepare_browse_endpoint("ALBUM", browseId) + body = {'browseId': browseId} endpoint = 'browse' response = self._send_request(endpoint, body) album = parse_album_header(response) From 7a49cd719e135a7861f93c7f7f4387fd126a044b Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 28 Oct 2021 22:01:02 +0200 Subject: [PATCH 016/238] remove get_album framework mutations code as rollout is now complete --- ytmusicapi/mixins/browsing.py | 66 ++--------------------------------- 1 file changed, 3 insertions(+), 63 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 669e63c2..b782d7c3 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -464,69 +464,9 @@ def get_album(self, browseId: str) -> Dict: body = {'browseId': browseId} endpoint = 'browse' response = self._send_request(endpoint, body) - album = {} - data = nav(response, FRAMEWORK_MUTATIONS, True) - if not data: - album = parse_album_header(response) - results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) - album['tracks'] = parse_playlist_items(results['contents']) - else: - album_data = find_object_by_key(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'] = find_object_by_key(data, 'musicAlbumReleaseDetail', 'payload', - True)['description'] - album['thumbnails'] = album_data['thumbnailDetails']['thumbnails'] - album['artists'] = [] - artists_data = find_objects_by_key(data, 'musicArtist', 'payload') - for artist in artists_data: - album['artists'].append({ - 'name': artist['musicArtist']['name'], - 'id': artist['musicArtist']['externalChannelId'] - }) - album['tracks'] = [] - - track_library_details = {} - for item in data: - if 'musicTrackUserDetail' in item['payload']: - like_state = item['payload']['musicTrackUserDetail']['likeState'].split( - '_')[-1] - parent_track = item['payload']['musicTrackUserDetail']['parentTrack'] - like_state = 'INDIFFERENT' if like_state in ['NEUTRAL', 'UNKNOWN' - ] else like_state[:-1] - track_library_details[parent_track] = like_state - - if 'musicLibraryEdit' in item['payload']: - entity_key = item['entityKey'] - track_library_details[entity_key] = { - 'add': item['payload']['musicLibraryEdit']['addToLibraryFeedbackToken'], - 'remove': - item['payload']['musicLibraryEdit']['removeFromLibraryFeedbackToken'] - } - - for item in data[3:]: - if 'musicTrack' in item['payload']: - music_track = item['payload']['musicTrack'] - track = {} - track['index'] = music_track['albumTrackIndex'] - track['title'] = music_track['title'] - track['thumbnails'] = music_track['thumbnailDetails']['thumbnails'] - track['artists'] = music_track['artistNames'] - # in case the song is unavailable, there is no videoId - track['videoId'] = music_track['videoId'] if 'videoId' in item['payload'][ - 'musicTrack'] else None - # very occasionally lengthMs is not returned - track['lengthMs'] = music_track[ - 'lengthMs'] if 'lengthMs' in music_track else None - track['likeStatus'] = track_library_details[item['entityKey']] - track['isExplicit'] = music_track['contentRating'][ - 'explicitType'] == 'MUSIC_ENTITY_EXPLICIT_TYPE_EXPLICIT' - if 'libraryEdit' in music_track: - track['feedbackTokens'] = track_library_details[music_track['libraryEdit']] - album['tracks'].append(track) + album = parse_album_header(response) + results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) + album['tracks'] = parse_playlist_items(results['contents']) return album From 7306069ebe347f5a00b65f0556c57fef9a36d728 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 28 Oct 2021 22:19:02 +0200 Subject: [PATCH 017/238] explain limit parameter workings in docs (#190) --- docs/source/faq.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/source/faq.rst b/docs/source/faq.rst index 011cb0b5..73a03b17 100644 --- a/docs/source/faq.rst +++ b/docs/source/faq.rst @@ -72,3 +72,11 @@ What is a browseId? *********************************************************************** A ``browseId`` is an internal, globally unique identifier used by YouTube Music for browsable content. + +Why is ytmusicapi returning more results than requested with the limit parameter? +*********************************************************************** +YouTube Music always returns increments of a specific pagination value, usually between 20 and 100 items at a time. +This is the case if a ytmusicapi method supports the ``limit`` parameter. The default value of the ``limit`` parameter +indicates the server-side pagination increment. ytmusicapi will keep fetching continuations from the server until it has +reached at least the ``limit`` parameter, and return all of these results. + From 040532e193d72615768b487efc5f040848a7bca3 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 28 Oct 2021 22:24:30 +0200 Subject: [PATCH 018/238] update version to 0.19.4 --- ytmusicapi/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/_version.py b/ytmusicapi/_version.py index 1a95d562..8261536c 100644 --- a/ytmusicapi/_version.py +++ b/ytmusicapi/_version.py @@ -1 +1 @@ -__version__ = "0.19.3" +__version__ = "0.19.4" From ef842d4d657555503cac73523fdc91941bc2bc0d Mon Sep 17 00:00:00 2001 From: Austin Noto-Moniz Date: Sun, 28 Nov 2021 13:57:02 -0500 Subject: [PATCH 019/238] Flip re.search parameters when parsing playlist info. They were passed backwards, so even for playlists which contain the info, it was not being captured. --- ytmusicapi/parsers/browsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 044447cf..21e03b5f 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -197,7 +197,7 @@ def parse_playlist(data): 'playlistId': nav(data, TITLE + NAVIGATION_BROWSE_ID)[2:], 'thumbnails': nav(data, THUMBNAIL_RENDERER) } - if len(data['subtitle']['runs']) == 3 and re.search(nav(data, SUBTITLE2), r'\d+ '): + if len(data['subtitle']['runs']) == 3 and re.search(r'\d+ ', nav(data, SUBTITLE2)): playlist['count'] = nav(data, SUBTITLE2).split(' ')[0] return playlist From 71b64796da4fb4f685ef82dedb2afa992f33be00 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 29 Nov 2021 11:52:47 +0100 Subject: [PATCH 020/238] fix get_watch_playlist results if only videoid is provided --- ytmusicapi/mixins/watch.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index 95e49881..d5e3dd2b 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -48,19 +48,21 @@ def get_watch_playlist(self, """ body = {'enablePersistentPlaylistPanel': True, 'isAudioOnly': True} + if not videoId and not playlistId: + raise Exception("You must provide either a video id, a playlist id, or both") if videoId: body['videoId'] = videoId + if not playlistId: + playlistId = "RDAMVM" + videoId if not params: body['watchEndpointMusicSupportedConfigs'] = { 'watchEndpointMusicConfig': { 'hasPersistentPlaylistPanel': True, - 'musicVideoType': "MUSIC_VIDEO_TYPE_OMV", + 'musicVideoType': "MUSIC_VIDEO_TYPE_ATV", } } - is_playlist = False - if playlistId: - body['playlistId'] = validate_playlist_id(playlistId) - is_playlist = body['playlistId'].startswith('PL') + body['playlistId'] = validate_playlist_id(playlistId) + is_playlist = body['playlistId'].startswith('PL') if params: body['params'] = params endpoint = 'next' From eb0293fa526bd5a2dc922bfc3ed17a840ef3d403 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 29 Nov 2021 12:02:26 +0100 Subject: [PATCH 021/238] fix search error if result thumbnail is missing --- ytmusicapi/parsers/browsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 21e03b5f..8f80a512 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -117,7 +117,7 @@ def parse_search_results(self, results, resultType=None, category=None): if resultType in ['song', 'album']: search_result['isExplicit'] = nav(data, BADGE_LABEL, True) == 'Explicit' - search_result['thumbnails'] = nav(data, THUMBNAILS) + search_result['thumbnails'] = nav(data, THUMBNAILS, True) search_results.append(search_result) return search_results From 55c795a13e76d47730502b4ab9ec56111b0995ef Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 1 Dec 2021 17:07:23 +0100 Subject: [PATCH 022/238] Update version to 0.19.5 --- ytmusicapi/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/_version.py b/ytmusicapi/_version.py index 8261536c..3f56ae56 100644 --- a/ytmusicapi/_version.py +++ b/ytmusicapi/_version.py @@ -1 +1 @@ -__version__ = "0.19.4" +__version__ = "0.19.5" From afad9d2933afb892ede40841786dafdce1d3b213 Mon Sep 17 00:00:00 2001 From: hibby50 Date: Thu, 9 Dec 2021 08:01:26 -0500 Subject: [PATCH 023/238] Respect the authuser defined in headers_json when uploading a song --- ytmusicapi/mixins/uploads.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index 835061ca..dc439999 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -203,7 +203,7 @@ def upload_song(self, filepath: str) -> Union[str, requests.Response]: + ', '.join(supported_filetypes)) headers = self.headers.copy() - upload_url = "https://upload.youtube.com/upload/usermusic/http?authuser=0" + upload_url = "https://upload.youtube.com/upload/usermusic/http?authuser=%s" % headers['X-Goog-AuthUser'] filesize = os.path.getsize(filepath) body = ("filename=" + ntpath.basename(filepath)).encode('utf-8') headers.pop('content-encoding', None) From 0cefc97d87fef7628349cfc7f900b1b32dbd65eb Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 12 Dec 2021 20:55:31 +0100 Subject: [PATCH 024/238] use CaseInsensitiveDict for headers --- ytmusicapi/mixins/uploads.py | 2 +- ytmusicapi/ytmusic.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index dc439999..0317cb1c 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -203,7 +203,7 @@ def upload_song(self, filepath: str) -> Union[str, requests.Response]: + ', '.join(supported_filetypes)) headers = self.headers.copy() - upload_url = "https://upload.youtube.com/upload/usermusic/http?authuser=%s" % headers['X-Goog-AuthUser'] + upload_url = "https://upload.youtube.com/upload/usermusic/http?authuser=%s" % headers['x-goog-authuser'] filesize = os.path.getsize(filepath) body = ("filename=" + ntpath.basename(filepath)).encode('utf-8') headers.pop('content-encoding', None) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index c5103ea7..e8583023 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -1,6 +1,7 @@ import requests import gettext import os +from requests.structures import CaseInsensitiveDict from functools import partial from contextlib import suppress from typing import Dict @@ -74,15 +75,14 @@ def __init__(self, self.proxies = proxies # prepare headers - self.headers = {} if auth: try: if os.path.isfile(auth): file = auth with open(file) as json_file: - self.headers = json.load(json_file) + self.headers = CaseInsensitiveDict(json.load(json_file)) else: - self.headers = json.loads(auth) + self.headers = CaseInsensitiveDict(json.loads(auth)) except Exception as e: print( @@ -120,7 +120,7 @@ def __init__(self, # verify authentication credentials work if auth: try: - cookie = self.headers.get('cookie', self.headers.get('Cookie')) + cookie = self.headers.get('cookie') self.sapisid = sapisid_from_cookie(cookie) except KeyError: raise Exception("Your cookie is missing the required value __Secure-3PAPISID") From 3b133a756dd4338f2d870992b5ae115312d72b89 Mon Sep 17 00:00:00 2001 From: Kenneth Bier Date: Sat, 11 Dec 2021 14:08:28 -0500 Subject: [PATCH 025/238] Fix issue #242 Fix issue [242](https://github.com/sigma67/ytmusicapi/issues/242) by changing run_count check to == 5. --- ytmusicapi/mixins/playlists.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index ce60a237..a1f6b84c 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -86,7 +86,7 @@ def get_playlist(self, playlistId: str, limit: int = 100) -> Dict: 'name': nav(header, SUBTITLE2), 'id': nav(header, ['subtitle', 'runs', 2] + NAVIGATION_BROWSE_ID, True) } - if run_count > 3: + if run_count == 5: playlist['year'] = nav(header, SUBTITLE3) song_count = to_int( From 909d4d367a4c995a96a56e1d82d7d9a6a4471430 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 12 Dec 2021 21:23:14 +0100 Subject: [PATCH 026/238] use session for get requests (closes #240) --- ytmusicapi/ytmusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index e8583023..b34d4077 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -143,7 +143,7 @@ def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") - return response_text def _send_get_request(self, url: str, params: Dict = None): - response = requests.get(url, params, headers=self.headers, proxies=self.proxies) + response = self._session.get(url, params=params, headers=self.headers, proxies=self.proxies) return response.text def _check_auth(self): From 71cc90f52a36ea0cc398571e9379cf655c24e49f Mon Sep 17 00:00:00 2001 From: Bruce Zhang Date: Mon, 13 Dec 2021 22:10:45 +0800 Subject: [PATCH 027/238] add zh_CN language --- ytmusicapi/locales/zh_CN/LC_MESSAGES/base.mo | Bin 0 -> 737 bytes ytmusicapi/locales/zh_CN/LC_MESSAGES/base.pot | 58 ++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 ytmusicapi/locales/zh_CN/LC_MESSAGES/base.mo create mode 100644 ytmusicapi/locales/zh_CN/LC_MESSAGES/base.pot diff --git a/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.mo b/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.mo new file mode 100644 index 0000000000000000000000000000000000000000..5e45e20917e4d683b8f22a094ec743d6925880fc GIT binary patch literal 737 zcmYk2&1(}u7{*7fU#kb}sRzLUFM>?8)j+ydY0|DSm}Emt3KlQZWZbT7cEZf0SWg8( zEhshGs`!Zo(St>)2(@TDdF#!a2Tz+c`7ioTx+OmF%x_+1=ACEe-_gD$1Udvh23FvH z@CI1x$S#ET0sBJ^01rTY9C#3T61X2Y6w(CtL7fQeByca(HcPHLjDCp%dTMB&1L{~7M*twSWEZT(wTLx9qIRXMZKw7 zMItJcD;27Gbk_TSB9UMoRjiDp>pLEkNOIpnQc>k{AI-RB#GY`QM!0Z#dmv3Fm9ctH7>t}T#<;JT4_D7tA)8{i z)M@xC*FTR-(^N>NE))xwNUXO{N4&uVvHTJ*!_S#GHsvY<1#3^*jXLVQ{Th6K6s)hK z*5j4dVxxKgS^M2bw7J^o%*_MlgSFSqh0pCfKij{a2R~jkpT25+T1L%>ORblysI~Gy TV{`FabKwhWf4JLOuJ2$AlcvTz literal 0 HcmV?d00001 diff --git a/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.pot b/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.pot new file mode 100644 index 00000000..07c7e7cb --- /dev/null +++ b/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.pot @@ -0,0 +1,58 @@ +# Translations for ytmusicapi +# Copyright (C) 2021 sigma67 +# This file is distributed under the same license as ytmusicapi +# sigma67 +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Bruce Zhang \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: parsers/browsing.py:22 +msgid "artist" +msgstr "音乐人" + +#: parsers/browsing.py:22 +msgid "playlist" +msgstr "播放列表" + +#: parsers/browsing.py:23 +msgid "song" +msgstr "歌曲" + +#: parsers/browsing.py:23 +msgid "video" +msgstr "视频" + +#: parsers/browsing.py:24 +msgid "station" +msgstr "电台" + +#: parsers/browsing.py:128 +msgid "albums" +msgstr "专辑" + +#: parsers/browsing.py:128 +msgid "singles" +msgstr "单曲" + +#: parsers/browsing.py:128 +msgid "videos" +msgstr "视频" + +#: parsers/browsing.py:128 +msgid "playlists" +msgstr "精选" + +#: parsers/browsing.py:128 +msgid "related" +msgstr "粉丝可能还会喜欢" From 2aad7232f2f038bbcad203df4e8292be9e16d36f Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 20 Dec 2021 10:11:51 +0100 Subject: [PATCH 028/238] also split on non-breaking spaces --- ytmusicapi/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index b58bcf6c..f3b139b3 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -96,7 +96,7 @@ def get_datestamp(): def to_int(string): - number_string = string.split(' ')[0] + number_string = re.split('[\x20\xa0]', string)[0] try: int_value = locale.atoi(number_string) except ValueError: From 878ae9a9bf4cf3e4fd1c52e74570b762dc8c4824 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 20 Dec 2021 10:21:17 +0100 Subject: [PATCH 029/238] get_watch_playlist: recognize audioPlaylistIds as playlists to not error on continuations (#246) --- tests/test.py | 2 ++ ytmusicapi/mixins/watch.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test.py b/tests/test.py index 0a851c77..e278f09d 100644 --- a/tests/test.py +++ b/tests/test.py @@ -175,6 +175,8 @@ def test_get_charts(self): ############### def test_get_watch_playlist(self): + playlist = self.yt_auth.get_watch_playlist(playlistId="OLAK5uy_ln_o1YXFqK4nfiNuTfhJK2XcRNCxml0fY", limit=90) + self.assertGreaterEqual(len(playlist['tracks']), 90) playlist = self.yt_auth.get_watch_playlist("9mWr4c_ig54", limit=50) self.assertGreater(len(playlist['tracks']), 45) playlist = self.yt_auth.get_watch_playlist("UoAf_y9Ok4k") # private track diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index d5e3dd2b..bbe33152 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -62,7 +62,8 @@ def get_watch_playlist(self, } } body['playlistId'] = validate_playlist_id(playlistId) - is_playlist = body['playlistId'].startswith('PL') + is_playlist = body['playlistId'].startswith('PL') or \ + body['playlistId'].startswith('OLA') if params: body['params'] = params endpoint = 'next' From 3043116d69014b84814035d956a0d2b6e63781ae Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 20 Dec 2021 10:56:50 +0100 Subject: [PATCH 030/238] get_history: raise exception if watch history is disabled --- ytmusicapi/mixins/library.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index dfbf7351..5bd27e72 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -199,7 +199,10 @@ def get_history(self) -> List[Dict]: results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) songs = [] for content in results: - data = content['musicShelfRenderer']['contents'] + data = nav(content, MUSIC_SHELF + ['contents'], True) + if not data: + error = nav(content, ['musicNotifierShelfRenderer'] + TITLE, True) + raise Exception(error) menu_entries = [[-1] + MENU_SERVICE + FEEDBACK_TOKEN] songlist = parse_playlist_items(data, menu_entries) for song in songlist: From 221cce92aef2cb3cfc5fb2d4d06c49bd14ec494a Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 23 Dec 2021 12:34:33 +0100 Subject: [PATCH 031/238] fix isExplicit label for localized instances --- ytmusicapi/parsers/browsing.py | 2 +- ytmusicapi/parsers/explore.py | 2 +- ytmusicapi/parsers/playlists.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 8f80a512..89dc1966 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -115,7 +115,7 @@ def parse_search_results(self, results, resultType=None, category=None): continue if resultType in ['song', 'album']: - search_result['isExplicit'] = nav(data, BADGE_LABEL, True) == 'Explicit' + search_result['isExplicit'] = nav(data, BADGE_LABEL, True) is not None search_result['thumbnails'] = nav(data, THUMBNAILS, True) search_results.append(search_result) diff --git a/ytmusicapi/parsers/explore.py b/ytmusicapi/parsers/explore.py index 5abbd362..3b83617d 100644 --- a/ytmusicapi/parsers/explore.py +++ b/ytmusicapi/parsers/explore.py @@ -10,7 +10,7 @@ def parse_chart_song(data): 'videoId': nav(flex_0, TEXT_RUN + NAVIGATION_VIDEO_ID, True), 'artists': parse_song_artists(data, 1), 'thumbnails': nav(data, THUMBNAILS), - 'isExplicit': nav(data, BADGE_LABEL, True) == 'Explicit' + 'isExplicit': nav(data, BADGE_LABEL, True) is not None } flex_2 = get_flex_column_item(data, 2) if flex_2 and 'navigationEndpoint' in nav(flex_2, TEXT_RUN): diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index b675f1ec..0a9fc289 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -63,7 +63,7 @@ def parse_playlist_items(results, menu_entries: List[List] = None): isAvailable = data[ 'musicItemRendererDisplayPolicy'] != 'MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT' - isExplicit = nav(data, BADGE_LABEL, True) == 'Explicit' + isExplicit = nav(data, BADGE_LABEL, True) is not None song = { 'videoId': videoId, From 3436f2dcc471f6124c7ce4dc5c8f3267683b4f61 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 1 Jan 2022 19:54:42 +0100 Subject: [PATCH 032/238] add duration_seconds key to items with duration (closes #215) --- ytmusicapi/helpers.py | 14 ++ ytmusicapi/mixins/browsing.py | 342 ++++++++++++++++---------------- ytmusicapi/mixins/explore.py | 19 +- ytmusicapi/mixins/library.py | 4 +- ytmusicapi/mixins/playlists.py | 2 + ytmusicapi/mixins/uploads.py | 2 + ytmusicapi/parsers/playlists.py | 2 + ytmusicapi/parsers/songs.py | 5 +- ytmusicapi/parsers/uploads.py | 7 +- 9 files changed, 200 insertions(+), 197 deletions(-) diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index f3b139b3..128b2bf0 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -105,6 +105,20 @@ def to_int(string): return int_value +def parse_duration(duration): + if duration is None: + return duration + mapped_increments = zip([1, 60, 3600], reversed(duration.split(":"))) + seconds = sum(multiplier * int(time) for multiplier, time in mapped_increments) + return seconds + + +def sum_total_duration(item): + return sum( + [track['duration_seconds'] if 'duration_seconds' in track else 0 for track in item['tracks']] + ) + + def i18n(method): @wraps(method) def _impl(self, *method_args, **method_kwargs): diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index b782d7c3..1833feb3 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -49,7 +49,8 @@ def search(self, } ], "views": "1.4M", - "duration": "4:38" + "duration": "4:38", + "duration_seconds": 278 }, { "category": "Songs", @@ -67,6 +68,7 @@ def search(self, "id": "MPREb_9nqEki4ZDpp" }, "duration": "4:19", + "duration_seconds": 259 "isExplicit": false, "feedbackTokens": { "add": null, @@ -103,7 +105,8 @@ def search(self, } ], "views": "386M", - "duration": "4:38" + "duration": "4:38", + "duration_seconds": 278 }, { "category": "Artists", @@ -121,7 +124,8 @@ def search(self, endpoint = 'search' search_results = [] filters = [ - 'albums', 'artists', 'playlists', 'community_playlists', 'featured_playlists', 'songs', 'videos' + 'albums', 'artists', 'playlists', 'community_playlists', 'featured_playlists', 'songs', + 'videos' ] if filter and filter not in filters: raise Exception( @@ -146,7 +150,8 @@ def search(self, if 'tabbedSearchResultsRenderer' in response['contents']: tab_index = 0 if not scope or filter else scopes.index(scope) + 1 - results = response['contents']['tabbedSearchResultsRenderer']['tabs'][tab_index]['tabRenderer']['content'] + results = response['contents']['tabbedSearchResultsRenderer']['tabs'][tab_index][ + 'tabRenderer']['content'] else: results = response['contents'] @@ -178,7 +183,8 @@ def search(self, request_func = lambda additionalParams: self._send_request( endpoint, body, additionalParams) - parse_func = lambda contents: self.parser.parse_search_results(contents, type, category) + parse_func = lambda contents: self.parser.parse_search_results( + contents, type, category) search_results.extend( get_continuations(res['musicShelfRenderer'], 'musicShelfContinuation', @@ -427,38 +433,42 @@ def get_album(self, browseId: str) -> Dict: :param browseId: browseId of the album, for example returned by :py:func:`search` - :return: Dictionary with title, description, artist and tracks. + :return: Dictionary with album and track metadata. Each track is in the following format:: { - "title": "Seven", - "trackCount": "7", - "durationMs": "1439579", - "playlistId": "OLAK5uy_kGnhwT08mQMGw8fArBowdtlew3DpgUt9c", - "releaseDate": { - "year": 2016, - "month": 10, - "day": 28 - }, - "description": "Seven is ...", - "thumbnails": [...], - "artists": [ - { - "name": "Martin Garrix", - "id": "UCqJnSdHjKtfsrHi9aI-9d3g" - } - ], - "tracks": [ - { - "index": "1", - "title": "WIEE (feat. Mesto)", - "artist": "Martin Garrix", - "videoId": "8xMNeXI9wxI", - "lengthMs": "203406", - "likeStatus": "INDIFFERENT" - } - ] + "title": "Revival", + "type": "Album", + "thumbnails": [], + "description": "Revival is the ninth studio album by American rapper Eminem. ...", + "artists": [{ + "name": "Eminem", + "id": "UCedvOgsKFzcK3hA5taf3KoQ" + }], + "year": "2017", + "trackCount": 19, + "duration": "1 hour, 17 minutes", + "duration_seconds": 4657, + "audioPlaylistId": "OLAK5uy_nMr9h2VlS-2PULNz3M3XVXQj_P3C2bqaY", + "tracks": [ + { + "videoId": "iKLU7z_xdYQ", + "title": "Walk On Water (feat. Beyoncé)", + "artists": None, + "album": None, + "likeStatus": "INDIFFERENT", + "thumbnails": None, + "isAvailable": True, + "isExplicit": True, + "duration": "5:03", + "feedbackTokens": + { + "add": "AB9zfpJww...", + "remove": "AB9zfpI807..." + } + } + ] } """ body = {'browseId': browseId} @@ -467,6 +477,7 @@ def get_album(self, browseId: str) -> Dict: album = parse_album_header(response) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) album['tracks'] = parse_playlist_items(results['contents']) + album['duration_seconds'] = sum_total_duration(album) return album @@ -482,152 +493,135 @@ def get_song(self, videoId: str, signatureTimestamp: int = None) -> Dict: Example:: { - "videoDetails": { - "allowRatings": true, - "author": "Oasis - Topic", - "averageRating": 4.5783687, - "channelId": "UCmMUZbaYdNH0bEd1PAlAqsA", - "isCrawlable": true, - "isLiveContent": false, - "isOwnerViewing": false, - "isPrivate": false, - "isUnpluggedCorpus": false, - "lengthSeconds": "259", - "musicVideoType": "MUSIC_VIDEO_TYPE_ATV", - "thumbnail": { - "thumbnails": [...] - }, - "title": "Wonderwall", - "videoId": "ZrOKjDZOtkA", - "viewCount": "27429003" - }, - "microformat": { - "microformatDataRenderer": { - "androidPackage": "com.google.android.apps.youtube.music", - "appName": "YouTube Music", - "availableCountries": ["AE",...], - "category": "Music", - "description": "Provided to YouTube by Ignition Wonderwall · Oasis ...", - "familySafe": true, - "iosAppArguments": "https://music.youtube.com/watch?v=ZrOKjDZOtkA", - "iosAppStoreId": "1017492454", - "linkAlternates": [ - { - "hrefUrl": "android-app://com.google.android.youtube/http/youtube.com/watch?v=ZrOKjDZOtkA" - }, - { - "hrefUrl": "ios-app://544007664/http/youtube.com/watch?v=ZrOKjDZOtkA" + "playabilityStatus": { + "status": "OK", + "playableInEmbed": true, + "audioOnlyPlayability": { + "audioOnlyPlayabilityRenderer": { + "trackingParams": "CAEQx2kiEwiuv9X5i5H1AhWBvlUKHRoZAHk=", + "audioOnlyAvailability": "FEATURE_AVAILABILITY_ALLOWED" + } }, - { - "alternateType": "application/json+oembed", - "hrefUrl": "https://www.youtube.com/oembed?format=json&url=...", - "title": "Wonderwall (Remastered)" + "miniplayer": { + "miniplayerRenderer": { + "playbackMode": "PLAYBACK_MODE_ALLOW" + } }, - { - "alternateType": "text/xml+oembed", - "hrefUrl": "https://www.youtube.com/oembed?format=xml&url=...", - "title": "Wonderwall (Remastered)" - } - ], - "noindex": false, - "ogType": "video.other", - "pageOwnerDetails": { - "externalChannelId": "UCmMUZbaYdNH0bEd1PAlAqsA", - "name": "Oasis - Topic", - "youtubeProfileUrl": "http://www.youtube.com/channel/UCmMUZbaYdNH0bEd1PAlAqsA" - }, - "paid": false, - "publishDate": "2017-01-25", - "schemaDotOrgType": "http://schema.org/VideoObject", - "siteName": "YouTube Music", - "tags": ["Oasis",...], - "thumbnail": { - "thumbnails": [ - { - "height": 720, - "url": "https://i.ytimg.com/vi/ZrOKjDZOtkA/maxresdefault.jpg", - "width": 1280 - } + "contextParams": "Q0FBU0FnZ0M=" + }, + "streamingData": { + "expiresInSeconds": "21540", + "adaptiveFormats": [ + { + "itag": 140, + "url": "https://rr1---sn-h0jelnez.c.youtube.com/videoplayback?expire=1641080272...", + "mimeType": "audio/mp4; codecs=\"mp4a.40.2\"", + "bitrate": 131007, + "initRange": { + "start": "0", + "end": "667" + }, + "indexRange": { + "start": "668", + "end": "999" + }, + "lastModified": "1620321966927796", + "contentLength": "3967382", + "quality": "tiny", + "projectionType": "RECTANGULAR", + "averageBitrate": 129547, + "highReplication": true, + "audioQuality": "AUDIO_QUALITY_MEDIUM", + "approxDurationMs": "245000", + "audioSampleRate": "44100", + "audioChannels": 2, + "loudnessDb": -1.3000002 + } ] - }, - "title": "Wonderwall (Remastered) - YouTube Music", - "twitterCardType": "player", - "twitterSiteHandle": "@YouTubeMusic", - "unlisted": false, - "uploadDate": "2017-01-25", - "urlApplinksAndroid": "vnd.youtube.music://music.youtube.com/watch?v=ZrOKjDZOtkA&feature=applinks", - "urlApplinksIos": "vnd.youtube.music://music.youtube.com/watch?v=ZrOKjDZOtkA&feature=applinks", - "urlCanonical": "https://music.youtube.com/watch?v=ZrOKjDZOtkA", - "urlTwitterAndroid": "vnd.youtube.music://music.youtube.com/watch?v=ZrOKjDZOtkA&feature=twitter-deep-link", - "urlTwitterIos": "vnd.youtube.music://music.youtube.com/watch?v=ZrOKjDZOtkA&feature=twitter-deep-link", - "videoDetails": { - "durationIso8601": "PT4M19S", - "durationSeconds": "259", - "externalVideoId": "ZrOKjDZOtkA" - }, - "viewCount": "27429003" - } - }, - "playabilityStatus": { - "contextParams": "Q0FFU0FnZ0I=", - "miniplayer": { - "miniplayerRenderer": { - "playbackMode": "PLAYBACK_MODE_ALLOW" - } }, - "playableInEmbed": true, - "status": "OK" - }, - "streamingData": { - "adaptiveFormats": [ - { - "approxDurationMs": "258760", - "averageBitrate": 178439, - "bitrate": 232774, - "contentLength": "5771637", - "fps": 25, - "height": 1080, - "indexRange": { - "end": "1398", - "start": "743" - }, - "initRange": { - "end": "742", - "start": "0" + "videoDetails": { + "videoId": "AjXQiKP5kMs", + "title": "Sparks", + "lengthSeconds": "245", + "channelId": "UCvCk2zFqkCYzpnSgWfx0qOg", + "isOwnerViewing": false, + "isCrawlable": false, + "thumbnail": { + "thumbnails": [] }, - "itag": 137, - "lastModified": "1614620567944400", - "mimeType": "video/mp4; codecs=\"avc1.640020\"", - "projectionType": "RECTANGULAR", - "quality": "hd1080", - "qualityLabel": "1080p", - "signatureCipher": "s=_xxxOq0QJ8...", - "width": 1078 - }[...] - ], - "expiresInSeconds": "21540", - "formats": [ - { - "approxDurationMs": "258809", - "audioChannels": 2, - "audioQuality": "AUDIO_QUALITY_LOW", - "audioSampleRate": "44100", - "averageBitrate": 179462, - "bitrate": 179496, - "contentLength": "5805816", - "fps": 25, - "height": 360, - "itag": 18, - "lastModified": "1614620870611066", - "mimeType": "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\"", - "projectionType": "RECTANGULAR", - "quality": "medium", - "qualityLabel": "360p", - "signatureCipher": "s=kXXXOq0QJ8...", - "width": 360 - } - ] - } + "allowRatings": true, + "viewCount": "12", + "author": "Thomas Bergersen", + "isPrivate": true, + "isUnpluggedCorpus": false, + "musicVideoType": "MUSIC_VIDEO_TYPE_PRIVATELY_OWNED_TRACK", + "isLiveContent": false + }, + "microformat": { + "microformatDataRenderer": { + "urlCanonical": "https://music.youtube.com/watch?v=AjXQiKP5kMs", + "title": "Sparks - YouTube Music", + "description": "Uploaded to YouTube via YouTube Music Sparks", + "thumbnail": { + "thumbnails": [ + { + "url": "https://i.ytimg.com/vi/AjXQiKP5kMs/hqdefault.jpg", + "width": 480, + "height": 360 + } + ] + }, + "siteName": "YouTube Music", + "appName": "YouTube Music", + "androidPackage": "com.google.android.apps.youtube.music", + "iosAppStoreId": "1017492454", + "iosAppArguments": "https://music.youtube.com/watch?v=AjXQiKP5kMs", + "ogType": "video.other", + "urlApplinksIos": "vnd.youtube.music://music.youtube.com/watch?v=AjXQiKP5kMs&feature=applinks", + "urlApplinksAndroid": "vnd.youtube.music://music.youtube.com/watch?v=AjXQiKP5kMs&feature=applinks", + "urlTwitterIos": "vnd.youtube.music://music.youtube.com/watch?v=AjXQiKP5kMs&feature=twitter-deep-link", + "urlTwitterAndroid": "vnd.youtube.music://music.youtube.com/watch?v=AjXQiKP5kMs&feature=twitter-deep-link", + "twitterCardType": "player", + "twitterSiteHandle": "@YouTubeMusic", + "schemaDotOrgType": "http://schema.org/VideoObject", + "noindex": true, + "unlisted": true, + "paid": false, + "familySafe": true, + "pageOwnerDetails": { + "name": "Music Library Uploads", + "externalChannelId": "UCvCk2zFqkCYzpnSgWfx0qOg", + "youtubeProfileUrl": "http://www.youtube.com/channel/UCvCk2zFqkCYzpnSgWfx0qOg" + }, + "videoDetails": { + "externalVideoId": "AjXQiKP5kMs", + "durationSeconds": "246", + "durationIso8601": "PT4M6S" + }, + "linkAlternates": [ + { + "hrefUrl": "android-app://com.google.android.youtube/http/youtube.com/watch?v=AjXQiKP5kMs" + }, + { + "hrefUrl": "ios-app://544007664/http/youtube.com/watch?v=AjXQiKP5kMs" + }, + { + "hrefUrl": "https://www.youtube.com/oembed?format=json&url=https%3A%2F%2Fmusic.youtube.com%2Fwatch%3Fv%3DAjXQiKP5kMs", + "title": "Sparks", + "alternateType": "application/json+oembed" + }, + { + "hrefUrl": "https://www.youtube.com/oembed?format=xml&url=https%3A%2F%2Fmusic.youtube.com%2Fwatch%3Fv%3DAjXQiKP5kMs", + "title": "Sparks", + "alternateType": "text/xml+oembed" + } + ], + "viewCount": "12", + "publishDate": "1969-12-31", + "category": "Music", + "uploadDate": "1969-12-31" + } + } } """ diff --git a/ytmusicapi/mixins/explore.py b/ytmusicapi/mixins/explore.py index dadf71c6..d2bc1ed2 100644 --- a/ytmusicapi/mixins/explore.py +++ b/ytmusicapi/mixins/explore.py @@ -94,7 +94,7 @@ def get_charts(self, country: str = 'ZZ') -> Dict: :param country: ISO 3166-1 Alpha-2 country code. Default: ZZ = Global :return: Dictionary containing chart songs (only if authenticated), chart videos, chart artists and - trending videos. + trending videos. Example:: @@ -123,18 +123,7 @@ def get_charts(self, country: str = 'ZZ') -> Dict: "id": "UCLusb4T2tW3gOpJS1fJ-A9g" } ], - "thumbnails": [ - { - "url": "https://lh3.googleusercontent.com/HtT4fwjY_SC7F2reuC1F3pREiq0z5G1XnO_IvhkI0LvCmdz05c8mhuZ6k3MiROvwH52TSJNj33TxnQIo=w60-h60-l90-rj", - "width": 60, - "height": 60 - }, - { - "url": "https://lh3.googleusercontent.com/HtT4fwjY_SC7F2reuC1F3pREiq0z5G1XnO_IvhkI0LvCmdz05c8mhuZ6k3MiROvwH52TSJNj33TxnQIo=w120-h120-l90-rj", - "width": 120, - "height": 120 - } - ], + "thumbnails": [...], "isExplicit": true, "album": { "name": "Outside (Better Days)", @@ -237,9 +226,7 @@ def get_charts(self, country: str = 'ZZ') -> Dict: } if has_songs: - charts['songs'].update({ - 'items': parse_chart(0, parse_chart_song, MRLIR) - }) + charts['songs'].update({'items': parse_chart(0, parse_chart_song, MRLIR)}) charts['videos']['items'] = parse_chart(1, parse_video, MTRIR) charts['artists']['items'] = parse_chart(2, parse_chart_artist, MRLIR) diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index 5bd27e72..7005047b 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -109,10 +109,10 @@ def get_library_albums(self, limit: int = 25, order: str = None) -> List[Dict]: "title": "Beautiful", "type": "Album", "thumbnails": [...], - "artists": { + "artists": [{ "name": "Project 46", "id": "UCXFv36m62USAN5rnVct9B4g" - }, + }], "year": "2015" } """ diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index a1f6b84c..5fa976cd 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -26,6 +26,7 @@ def get_playlist(self, playlistId: str, limit: int = 100) -> Dict: "author": "sigmatics", "year": "2020", "duration": "6+ hours", + "duration_seconds": 52651, "trackCount": 237, "tracks": [ { @@ -113,6 +114,7 @@ def get_playlist(self, playlistId: str, limit: int = 100) -> Dict: songs_to_get - len(playlist['tracks']), request_func, parse_func)) + playlist['duration_seconds'] = sum_total_duration(playlist) return playlist def get_playlist_suggestions(self, suggestions_token: str) -> Dict: diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index 0317cb1c..a71f2466 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -167,6 +167,7 @@ def get_library_upload_album(self, browseId: str) -> Dict: "videoId": "FVo-UZoPygI", "title": "Feel So Close", "duration": "4:15", + "duration_seconds": 255, "artists": None, "album": { "name": "18 Months", @@ -183,6 +184,7 @@ def get_library_upload_album(self, browseId: str) -> Dict: album = parse_album_header(response) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) album['tracks'] = parse_uploaded_items(results['contents']) + album['duration_seconds'] = sum_total_duration(album) return album def upload_song(self, filepath: str) -> Union[str, requests.Response]: diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index 0a9fc289..45b5baf5 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -1,4 +1,5 @@ from .utils import * +from ..helpers import parse_duration from .songs import * from typing import List @@ -77,6 +78,7 @@ def parse_playlist_items(results, menu_entries: List[List] = None): } if duration: song['duration'] = duration + song['duration_seconds'] = parse_duration(duration) if setVideoId: song['setVideoId'] = setVideoId if feedback_tokens: diff --git a/ytmusicapi/parsers/songs.py b/ytmusicapi/parsers/songs.py index 60bbdded..e54dbbb5 100644 --- a/ytmusicapi/parsers/songs.py +++ b/ytmusicapi/parsers/songs.py @@ -1,4 +1,5 @@ from .utils import * +from ..helpers import parse_duration import re @@ -30,7 +31,8 @@ def parse_song_runs(runs): if 'navigationEndpoint' in run: # artist or album item = {'name': text, 'id': nav(run, NAVIGATION_BROWSE_ID, True)} - if item['id'] and (item['id'].startswith('MPRE') or "release_detail" in item['id']): # album + if item['id'] and (item['id'].startswith('MPRE') + or "release_detail" in item['id']): # album parsed['album'] = item else: # artist parsed['artists'].append(item) @@ -42,6 +44,7 @@ def parse_song_runs(runs): elif re.match(r"^(\d+:)*\d+:\d+$", text): parsed['duration'] = text + parsed['duration_seconds'] = parse_duration(text) elif re.match(r"^\d{4}$", text): parsed['year'] = text diff --git a/ytmusicapi/parsers/uploads.py b/ytmusicapi/parsers/uploads.py index 588c6b7b..b51fac17 100644 --- a/ytmusicapi/parsers/uploads.py +++ b/ytmusicapi/parsers/uploads.py @@ -24,13 +24,12 @@ def parse_uploaded_items(results): 'videoId': videoId, 'title': title, 'duration': duration, - 'artists': None, - 'album': None, + 'duration_seconds': parse_duration(duration), + 'artists': parse_song_artists(data, 1), + 'album': parse_song_album(data, 2), 'likeStatus': like, 'thumbnails': thumbnails } - song['artists'] = parse_song_artists(data, 1) - song['album'] = parse_song_album(data, 2) songs.append(song) From 64276bc85e0b478ebce08952504fd64a2f5002e5 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 1 Jan 2022 19:59:50 +0100 Subject: [PATCH 033/238] Update version to 0.20.0 --- ytmusicapi/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/_version.py b/ytmusicapi/_version.py index 3f56ae56..5f4bb0b3 100644 --- a/ytmusicapi/_version.py +++ b/ytmusicapi/_version.py @@ -1 +1 @@ -__version__ = "0.19.5" +__version__ = "0.20.0" From 7bc65ba15cba8d48ab8077f0dbd1be89f2402f6e Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 30 Jan 2022 14:39:22 +0100 Subject: [PATCH 034/238] properly determine separator for artists runs on videos (closes #254) --- ytmusicapi/parsers/browsing.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 89dc1966..9027a50b 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -180,10 +180,15 @@ def parse_single(result): def parse_video(result): runs = result['subtitle']['runs'] + artists_len = len(runs) + try: + artists_len = runs.index({'text': ' • '}) + except ValueError: + pass video = { 'title': nav(result, TITLE_TEXT), 'videoId': nav(result, NAVIGATION_VIDEO_ID), - 'artists': parse_song_artists_runs(runs[:-2]), + 'artists': parse_song_artists_runs(runs[:artists_len]), 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), 'thumbnails': nav(result, THUMBNAIL_RENDERER, True) } From 2e8c09a4307e1ea1d81306bb3b20b700be825e4c Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 30 Jan 2022 14:50:41 +0100 Subject: [PATCH 035/238] get_charts: fix unavailable song error in trending section --- ytmusicapi/parsers/browsing.py | 6 +----- ytmusicapi/parsers/explore.py | 9 +++++---- ytmusicapi/parsers/utils.py | 9 +++++++++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 9027a50b..c176f77e 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -180,11 +180,7 @@ def parse_single(result): def parse_video(result): runs = result['subtitle']['runs'] - artists_len = len(runs) - try: - artists_len = runs.index({'text': ' • '}) - except ValueError: - pass + artists_len = get_dot_separator_index(runs) video = { 'title': nav(result, TITLE_TEXT), 'videoId': nav(result, NAVIGATION_VIDEO_ID), diff --git a/ytmusicapi/parsers/explore.py b/ytmusicapi/parsers/explore.py index 3b83617d..ecb13d10 100644 --- a/ytmusicapi/parsers/explore.py +++ b/ytmusicapi/parsers/explore.py @@ -43,12 +43,13 @@ def parse_chart_artist(data): def parse_chart_trending(data): flex_0 = get_flex_column_item(data, 0) artists = parse_song_artists(data, 1) - views = artists.pop() # last item is views for some reason - views = views['name'].split(' ')[0] + index = get_dot_separator_index(artists) + # last item is views for some reason + views = None if index == len(artists) else artists.pop()['name'].split(' ')[0] return { 'title': nav(flex_0, TEXT_RUN_TEXT), - 'videoId': nav(flex_0, TEXT_RUN + NAVIGATION_VIDEO_ID), - 'playlistId': nav(flex_0, TEXT_RUN + NAVIGATION_PLAYLIST_ID), + 'videoId': nav(flex_0, TEXT_RUN + NAVIGATION_VIDEO_ID, True), + 'playlistId': nav(flex_0, TEXT_RUN + NAVIGATION_PLAYLIST_ID, True), 'artists': artists, 'thumbnails': nav(data, THUMBNAILS), 'views': views diff --git a/ytmusicapi/parsers/utils.py b/ytmusicapi/parsers/utils.py index f4dd1fda..5f46f687 100644 --- a/ytmusicapi/parsers/utils.py +++ b/ytmusicapi/parsers/utils.py @@ -172,3 +172,12 @@ def find_objects_by_key(object_list, key, nested=None): if key in item: objects.append(item) return objects + + +def get_dot_separator_index(runs): + index = len(runs) + try: + index = runs.index({'text': ' • '}) + except ValueError: + len(runs) + return index From d046011b731ab94a53abdf1f11c763296b04804a Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 10 Feb 2022 11:24:05 +0100 Subject: [PATCH 036/238] get_album: add album and artists to tracks (closes #257) --- ytmusicapi/mixins/browsing.py | 68 ++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 1833feb3..bf349677 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -438,37 +438,44 @@ def get_album(self, browseId: str) -> Dict: Each track is in the following format:: { - "title": "Revival", - "type": "Album", - "thumbnails": [], - "description": "Revival is the ninth studio album by American rapper Eminem. ...", - "artists": [{ - "name": "Eminem", - "id": "UCedvOgsKFzcK3hA5taf3KoQ" - }], - "year": "2017", - "trackCount": 19, - "duration": "1 hour, 17 minutes", - "duration_seconds": 4657, - "audioPlaylistId": "OLAK5uy_nMr9h2VlS-2PULNz3M3XVXQj_P3C2bqaY", - "tracks": [ + "title": "Revival", + "type": "Album", + "thumbnails": [], + "description": "Revival is the...", + "artists": [ + { + "name": "Eminem", + "id": "UCedvOgsKFzcK3hA5taf3KoQ" + } + ], + "year": "2017", + "trackCount": 19, + "duration": "1 hour, 17 minutes", + "audioPlaylistId": "OLAK5uy_nMr9h2VlS-2PULNz3M3XVXQj_P3C2bqaY", + "tracks": [ + { + "videoId": "iKLU7z_xdYQ", + "title": "Walk On Water (feat. Beyoncé)", + "artists": [ { - "videoId": "iKLU7z_xdYQ", - "title": "Walk On Water (feat. Beyoncé)", - "artists": None, - "album": None, - "likeStatus": "INDIFFERENT", - "thumbnails": None, - "isAvailable": True, - "isExplicit": True, - "duration": "5:03", - "feedbackTokens": - { - "add": "AB9zfpJww...", - "remove": "AB9zfpI807..." - } + "name": "Eminem", + "id": "UCedvOgsKFzcK3hA5taf3KoQ" } - ] + ], + "album": "Revival", + "likeStatus": "INDIFFERENT", + "thumbnails": null, + "isAvailable": true, + "isExplicit": true, + "duration": "5:03", + "duration_seconds": 303, + "feedbackTokens": { + "add": "AB9zfpK...", + "remove": "AB9zfpK..." + } + } + ], + "duration_seconds": 4657 } """ body = {'browseId': browseId} @@ -478,6 +485,9 @@ def get_album(self, browseId: str) -> Dict: results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) album['tracks'] = parse_playlist_items(results['contents']) album['duration_seconds'] = sum_total_duration(album) + for i, track in enumerate(album['tracks']): + album['tracks'][i]['album'] = album['title'] + album['tracks'][i]['artists'] = album['artists'] return album From c1170b9c932bd73b17246504f5a5156ab4b82a4c Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 27 Feb 2022 10:24:14 +0100 Subject: [PATCH 037/238] feature: get_home (closes #251) --- docs/source/reference.rst | 4 +- tests/test.py | 11 +++- ytmusicapi/mixins/browsing.py | 107 +++++++++++++++++++++++++++++++++ ytmusicapi/parsers/__init__.py | 13 ++-- ytmusicapi/parsers/browsing.py | 62 ++++++++++++++++++- 5 files changed, 188 insertions(+), 9 deletions(-) diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 3c85fd7e..8af312fa 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -21,11 +21,13 @@ Search Browsing -------- +.. automethod:: YTMusic.get_home .. automethod:: YTMusic.get_artist .. automethod:: YTMusic.get_artist_albums +.. automethod:: YTMusic.get_album +.. automethod:: YTMusic.get_album_browse_id .. automethod:: YTMusic.get_user .. automethod:: YTMusic.get_user_playlists -.. automethod:: YTMusic.get_album .. automethod:: YTMusic.get_song .. automethod:: YTMusic.get_lyrics diff --git a/tests/test.py b/tests/test.py index e278f09d..a07b817a 100644 --- a/tests/test.py +++ b/tests/test.py @@ -35,6 +35,10 @@ def test_setup(self): # BROWSING ############### + def test_get_home(self): + result = self.yt_auth.get_home(limit=15) + self.assertGreaterEqual(len(result), 15) + def test_search(self): query = "edm playlist" self.assertRaises(Exception, self.yt_auth.search, query, filter="song") @@ -55,7 +59,9 @@ def test_search(self): self.assertGreater(len(results), 5) results = self.yt_auth.search("clasical music", filter='playlists', ignore_spelling=True) self.assertGreater(len(results), 5) - results = self.yt_auth.search("clasic rock", filter='community_playlists', ignore_spelling=True) + results = self.yt_auth.search("clasic rock", + filter='community_playlists', + ignore_spelling=True) self.assertGreater(len(results), 5) results = self.yt_auth.search("hip hop", filter='featured_playlists') self.assertGreater(len(results), 5) @@ -175,7 +181,8 @@ def test_get_charts(self): ############### def test_get_watch_playlist(self): - playlist = self.yt_auth.get_watch_playlist(playlistId="OLAK5uy_ln_o1YXFqK4nfiNuTfhJK2XcRNCxml0fY", limit=90) + playlist = self.yt_auth.get_watch_playlist( + playlistId="OLAK5uy_ln_o1YXFqK4nfiNuTfhJK2XcRNCxml0fY", limit=90) self.assertGreaterEqual(len(playlist['tracks']), 90) playlist = self.yt_auth.get_watch_playlist("9mWr4c_ig54", limit=50) self.assertGreater(len(playlist['tracks']), 45) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index bf349677..868116f4 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -7,6 +7,112 @@ class BrowsingMixin: + def get_home(self, limit=3) -> List[Dict]: + """ + Get the home page. + The home page is structured as titled rows, returning 3 rows of music suggestions at a time. + Content varies and may contain artist, album, song or playlist suggestions, sometimes mixed within the same row + + :param limit: Number of rows to return + :return: List of dictionaries keyed with 'title' text and 'contents' list + + Example list:: + + [ + { + "title": "Your morning music", + "contents": [ + { //album result + "title": "Sentiment", + "year": "Said The Sky", + "browseId": "MPREb_QtqXtd2xZMR", + "thumbnails": [...] + }, + { //playlist result + "title": "r/EDM top submissions 01/28/2022", + "playlistId": "PLz7-xrYmULdSLRZGk-6GKUtaBZcgQNwel", + "thumbnails": [...], + "description": "redditEDM • 161 songs", + "count": "161", + "author": [ + { + "name": "redditEDM", + "id": "UCaTrZ9tPiIGHrkCe5bxOGwA" + } + ] + } + ] + }, + { + "title": "Your favorites", + "contents": [ + { //artist result + "title": "Chill Satellite", + "browseId": "UCrPLFBWdOroD57bkqPbZJog", + "subscribers": "374", + "thumbnails": [...] + } + { //album result + "title": "Dragon", + "year": "Two Steps From Hell", + "browseId": "MPREb_M9aDqLRbSeg", + "thumbnails": [...] + } + ] + }, + { + "title": "Quick picks", + "contents": [ + { //song quick pick + "title": "Gravity", + "videoId": "EludZd6lfts", + "artists": [{ + "name": "yetep", + "id": "UCSW0r7dClqCoCvQeqXiZBlg" + }], + "thumbnails": [...], + "album": { + "title": "Gravity", + "browseId": "MPREb_D6bICFcuuRY" + } + }, + { //video quick pick + "title": "Gryffin & Illenium (feat. Daya) - Feel Good (L3V3LS Remix)", + "videoId": "bR5l0hJDnX8", + "artists": [ + { + "name": "L3V3LS", + "id": "UCCVNihbOdkOWw_-ajIYhAbQ" + } + ], + "thumbnails": [...], + "views": "10M views" + } + ] + } + ] + + """ + endpoint = 'browse' + body = {"browseId": "FEmusic_home"} + response = self._send_request(endpoint, body) + results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) + home = [] + home.extend(self.parser.parse_home(results)) + + section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) + if 'continuations' in section_list: + request_func = lambda additionalParams: self._send_request( + endpoint, body, additionalParams) + + parse_func = lambda contents: self.parser.parse_home(contents) + + home.extend( + get_continuations(section_list, 'sectionListContinuation', limit - len(home), + request_func, parse_func)) + + return home + def search(self, query: str, filter: str = None, @@ -416,6 +522,7 @@ def get_user_playlists(self, channelId: str, params: str) -> List[Dict]: def get_album_browse_id(self, audioPlaylistId: str): """ Get an album's browseId based on its audioPlaylistId + :param audioPlaylistId: id of the audio playlist (starting with `OLAK5uy_`) :return: browseId (starting with `MPREb_`) """ diff --git a/ytmusicapi/parsers/__init__.py b/ytmusicapi/parsers/__init__.py index e60512a4..390b1bbf 100644 --- a/ytmusicapi/parsers/__init__.py +++ b/ytmusicapi/parsers/__init__.py @@ -16,7 +16,11 @@ PLAY_BUTTON = [ 'overlay', 'musicItemThumbnailOverlayRenderer', 'content', 'musicPlayButtonRenderer' ] -NAVIGATION_BROWSE_ID = ['navigationEndpoint', 'browseEndpoint', 'browseId'] +NAVIGATION_BROWSE = ['navigationEndpoint', 'browseEndpoint'] +NAVIGATION_BROWSE_ID = NAVIGATION_BROWSE + ['browseId'] +PAGE_TYPE = [ + 'browseEndpointContextSupportedConfigs', 'browseEndpointContextMusicConfig', 'pageType' +] NAVIGATION_VIDEO_ID = ['navigationEndpoint', 'watchEndpoint', 'videoId'] NAVIGATION_PLAYLIST_ID = ['navigationEndpoint', 'watchEndpoint', 'playlistId'] NAVIGATION_WATCH_PLAYLIST_ID = ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'] @@ -28,8 +32,9 @@ FRAMEWORK_MUTATIONS = ['frameworkUpdates', 'entityBatchUpdate', 'mutations'] TITLE = ['title', 'runs', 0] TITLE_TEXT = ['title'] + RUN_TEXT -TEXT_RUN = ['text', 'runs', 0] -TEXT_RUN_TEXT = ['text', 'runs', 0, 'text'] +TEXT_RUNS = ['text', 'runs'] +TEXT_RUN = TEXT_RUNS + [0] +TEXT_RUN_TEXT = TEXT_RUN + ['text'] SUBTITLE = ['subtitle'] + RUN_TEXT SUBTITLE2 = ['subtitle', 'runs', 2, 'text'] SUBTITLE3 = ['subtitle', 'runs', 4, 'text'] @@ -45,4 +50,4 @@ CATEGORY_TITLE = ['musicNavigationButtonRenderer', 'buttonText'] + RUN_TEXT CATEGORY_PARAMS = ['musicNavigationButtonRenderer', 'clickCommand', 'browseEndpoint', 'params'] MRLIR = 'musicResponsiveListItemRenderer' -MTRIR = 'musicTwoRowItemRenderer' \ No newline at end of file +MTRIR = 'musicTwoRowItemRenderer' diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index c176f77e..4b64219a 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -8,6 +8,49 @@ class Parser: def __init__(self, language): self.lang = language + def parse_home(self, rows): + items = [] + for row in rows: + contents = [] + results = nav(row, CAROUSEL) + for result in results['contents']: + data = nav(result, [MTRIR], True) + content = None + if data: + page_type = nav(data, TITLE + NAVIGATION_BROWSE + PAGE_TYPE, True) + if page_type is None: # song + content = parse_song(data) + elif page_type == "MUSIC_PAGE_TYPE_ALBUM": + content = parse_album(data) + elif page_type == "MUSIC_PAGE_TYPE_ARTIST": + content = parse_related_artist(data) + elif page_type == "MUSIC_PAGE_TYPE_PLAYLIST": + content = parse_playlist(data) + else: + data = nav(result, [MRLIR]) + columns = [ + get_flex_column_item(data, i) for i in range(0, len(data['flexColumns'])) + ] + content = { + 'title': nav(columns[0], TEXT_RUN_TEXT), + 'videoId': nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID), + 'artists': parse_song_artists_runs(nav(columns[1], TEXT_RUNS)), + 'thumbnails': nav(data, THUMBNAILS) + } + if len(columns) > 2 and columns[2] is not None: + content['album'] = { + 'title': nav(columns[2], TEXT_RUN_TEXT), + 'browseId': nav(columns[2], TEXT_RUN + NAVIGATION_BROWSE_ID) + } + else: + content['artists'].pop() + content['views'] = nav(columns[1], TEXT_RUNS + [2, 'text']) + + contents.append(content) + + items.append({'title': nav(results, CAROUSEL_TITLE + ['text']), 'contents': contents}) + return items + @i18n def parse_search_results(self, results, resultType=None, category=None): search_results = [] @@ -178,6 +221,16 @@ def parse_single(result): } +def parse_song(result): + return { + 'title': nav(result, TITLE_TEXT), + 'artists': parse_song_artists_runs(result['subtitle']['runs'][2:]), + 'videoId': nav(result, NAVIGATION_VIDEO_ID), + 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), + 'thumbnails': nav(result, THUMBNAIL_RENDERER) + } + + def parse_video(result): runs = result['subtitle']['runs'] artists_len = get_dot_separator_index(runs) @@ -198,8 +251,13 @@ def parse_playlist(data): 'playlistId': nav(data, TITLE + NAVIGATION_BROWSE_ID)[2:], 'thumbnails': nav(data, THUMBNAIL_RENDERER) } - if len(data['subtitle']['runs']) == 3 and re.search(r'\d+ ', nav(data, SUBTITLE2)): - playlist['count'] = nav(data, SUBTITLE2).split(' ')[0] + subtitle = data['subtitle'] + if 'runs' in subtitle: + playlist['description'] = "".join([run['text'] for run in subtitle['runs']]) + if len(subtitle['runs']) == 3 and re.search(r'\d+ ', nav(data, SUBTITLE2)): + playlist['count'] = nav(data, SUBTITLE2).split(' ')[0] + playlist['author'] = parse_song_artists_runs(subtitle['runs'][:1]) + return playlist From c1313aac346c9003b31303de271ac0b3ab5163b6 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 27 Feb 2022 10:28:05 +0100 Subject: [PATCH 038/238] Update version to 0.21.0 --- ytmusicapi/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/_version.py b/ytmusicapi/_version.py index 5f4bb0b3..6a726d85 100644 --- a/ytmusicapi/_version.py +++ b/ytmusicapi/_version.py @@ -1 +1 @@ -__version__ = "0.20.0" +__version__ = "0.21.0" From 64a1f1de476dda60b275b02d87cc58083aa8acb3 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 27 Feb 2022 13:11:16 +0100 Subject: [PATCH 039/238] get_home: small fixes to runs parsing (#251) --- tests/test.py | 2 ++ ytmusicapi/mixins/browsing.py | 2 +- ytmusicapi/parsers/browsing.py | 17 ++++++++++------- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/test.py b/tests/test.py index a07b817a..7fe3a6b1 100644 --- a/tests/test.py +++ b/tests/test.py @@ -36,6 +36,8 @@ def test_setup(self): ############### def test_get_home(self): + result = self.yt.get_home(limit=6) + self.assertGreaterEqual(len(result), 6) result = self.yt_auth.get_home(limit=15) self.assertGreaterEqual(len(result), 15) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 868116f4..30fd94da 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -86,7 +86,7 @@ def get_home(self, limit=3) -> List[Dict]: } ], "thumbnails": [...], - "views": "10M views" + "views": "10M" } ] } diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 4b64219a..e8b88cde 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -12,7 +12,12 @@ def parse_home(self, rows): items = [] for row in rows: contents = [] - results = nav(row, CAROUSEL) + if CAROUSEL[0] in row: + results = nav(row, CAROUSEL) + elif 'musicImmersiveCarouselShelfRenderer' in row: + results = row['musicImmersiveCarouselShelfRenderer'] + else: + continue for result in results['contents']: data = nav(result, [MTRIR], True) content = None @@ -34,17 +39,14 @@ def parse_home(self, rows): content = { 'title': nav(columns[0], TEXT_RUN_TEXT), 'videoId': nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID), - 'artists': parse_song_artists_runs(nav(columns[1], TEXT_RUNS)), 'thumbnails': nav(data, THUMBNAILS) } + content.update(parse_song_runs(nav(columns[1], TEXT_RUNS))) if len(columns) > 2 and columns[2] is not None: content['album'] = { 'title': nav(columns[2], TEXT_RUN_TEXT), 'browseId': nav(columns[2], TEXT_RUN + NAVIGATION_BROWSE_ID) } - else: - content['artists'].pop() - content['views'] = nav(columns[1], TEXT_RUNS + [2, 'text']) contents.append(content) @@ -222,13 +224,14 @@ def parse_single(result): def parse_song(result): - return { + song = { 'title': nav(result, TITLE_TEXT), - 'artists': parse_song_artists_runs(result['subtitle']['runs'][2:]), 'videoId': nav(result, NAVIGATION_VIDEO_ID), 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), 'thumbnails': nav(result, THUMBNAIL_RENDERER) } + song.update(parse_song_runs(result['subtitle']['runs'])) + return song def parse_video(result): From c219f6723e17c030ff70839c636eff374b06fb89 Mon Sep 17 00:00:00 2001 From: HHongSeungWoo Date: Fri, 4 Mar 2022 18:22:49 +0900 Subject: [PATCH 040/238] feature/add ko locales --- ytmusicapi/locales/ko/LC_MESSAGES/base.mo | Bin 0 -> 742 bytes ytmusicapi/locales/ko/LC_MESSAGES/base.po | 58 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 ytmusicapi/locales/ko/LC_MESSAGES/base.mo create mode 100644 ytmusicapi/locales/ko/LC_MESSAGES/base.po diff --git a/ytmusicapi/locales/ko/LC_MESSAGES/base.mo b/ytmusicapi/locales/ko/LC_MESSAGES/base.mo new file mode 100644 index 0000000000000000000000000000000000000000..c77275b4eda3ea24940b9e43ab3242e4b6a8c683 GIT binary patch literal 742 zcmYk2&1=*^7{*7fU(tgVK@Wm+6lChhsl+NxyXhKCHcNK5pm>>Xrs-%lvt(wA#fzmb zNLTRU!g|<(;z7lO6~R4u^XAF3_y?HufAF1cWc$FI-#qj2&Xf6bcB+p+r=TuEDX3|v zpHNz74k2_5I9>1p@C5ivz>~nsz~jIh1x?@-_=Uokfk(mnK;5SSJP-At@aix^r(ym{ z!RH10z;iJF7O2mCEBL)w{{htZ|0?(!2rb8g;dspe>MT0%5hyL)OG{_gx&BN4|5!9$ z>9i!Gsp3+hc1$l>5wNyM74c-mL$c6`B&iD%8&#hKY@O>fqy*QQ_?~SA#PdnX z!gqYrbFSR?E4JZMsYpGgiG)xhHStozb#cY=Y&>UsmgCM_rBYzmYPVr)5_M?ANS)D^ ziCwEwZs;U)t~(#|8IeYlJ&?e2JbM!R`ph+BQ6!2baNR~5@+kILU4`f2$xYl`qp4)- zR-?W|W+(e}tW}m0J86m#UdzO@D_j{U@4wH!yaGJi>wV6*-|cOWP`>ji-x_8gKV)x) zW7FF$%qn!jEgr2YOk*i<%pWn7_jdn|o}#Qf%60}w_vpUJhhOvV0A(*<=Y!3BYxBV9 EACmIKxc~qF literal 0 HcmV?d00001 diff --git a/ytmusicapi/locales/ko/LC_MESSAGES/base.po b/ytmusicapi/locales/ko/LC_MESSAGES/base.po new file mode 100644 index 00000000..857bd77b --- /dev/null +++ b/ytmusicapi/locales/ko/LC_MESSAGES/base.po @@ -0,0 +1,58 @@ +# Translations for ytmusicapi +# Copyright (C) 2021 sigma67 +# This file is distributed under the same license as ytmusicapi +# sigma67 +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: parsers/browsing.py:22 +msgid "artist" +msgstr "아티스트" + +#: parsers/browsing.py:22 +msgid "playlist" +msgstr "재생목록" + +#: parsers/browsing.py:23 +msgid "song" +msgstr "노래" + +#: parsers/browsing.py:23 +msgid "video" +msgstr "동영상" + +#: parsers/browsing.py:24 +msgid "station" +msgstr "스테이션" + +#: parsers/browsing.py:128 +msgid "albums" +msgstr "앨범" + +#: parsers/browsing.py:128 +msgid "singles" +msgstr "싱글" + +#: parsers/browsing.py:128 +msgid "videos" +msgstr "동영상" + +#: parsers/browsing.py:128 +msgid "playlists" +msgstr "재싱목록" + +#: parsers/browsing.py:128 +msgid "related" +msgstr "fans might also like" \ No newline at end of file From e1c77f1da09b102e2be6e4e556e32bc2279be502 Mon Sep 17 00:00:00 2001 From: HHongSeungWoo Date: Fri, 4 Mar 2022 18:27:16 +0900 Subject: [PATCH 041/238] fix/number parse error --- ytmusicapi/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index 128b2bf0..ac6c0158 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -96,7 +96,7 @@ def get_datestamp(): def to_int(string): - number_string = re.split('[\x20\xa0]', string)[0] + number_string = re.sub('[^\\d]', '', string) try: int_value = locale.atoi(number_string) except ValueError: From 7ebb57d2a53ab43920e6b3f2639eea395ecaa266 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 10 Mar 2022 17:36:51 +0100 Subject: [PATCH 042/238] get_watch_playlist: parse and return counterpart for songs with video counterparts --- ytmusicapi/mixins/watch.py | 66 +++++++++++++++++++++++++++++++------ ytmusicapi/parsers/watch.py | 59 +++++++++++++++++++-------------- 2 files changed, 91 insertions(+), 34 deletions(-) diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index bbe33152..190b8512 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -20,26 +20,72 @@ def get_watch_playlist(self, :param playlistId: playlistId of the played playlist or album :param limit: minimum number of watch playlist items to return :param params: only used internally by :py:func:`get_watch_playlist_shuffle` - :return: List of watch playlist items. + :return: List of watch playlist items. The counterpart key is optional and only + appears if a song has a corresponding video counterpart (UI song/video + switcher). Example:: { "tracks": [ { - "title": "Interstellar (Main Theme) - Piano Version", - "byline": "Patrik Pietschmann • 47M views", - "length": "4:47", - "videoId": "4y33h81phKU", + "videoId": "9mWr4c_ig54", + "title": "Foolish Of Me (feat. Jonathan Mendelsohn)", + "length": "3:07", "thumbnail": [ { - "url": "https://i.ytimg.com/vi/4y...", - "width": 400, - "height": 225 + "url": "https://lh3.googleusercontent.com/ulK2YaLtOW0PzcN7ufltG6e4ae3WZ9Bvg8CCwhe6LOccu1lCKxJy2r5AsYrsHeMBSLrGJCNpJqXgwczk=w60-h60-l90-rj", + "width": 60, + "height": 60 + }... + ], + "feedbackTokens": { + "add": "AB9zfpIGg9XN4u2iJ...", + "remove": "AB9zfpJdzWLcdZtC..." + }, + "likeStatus": "INDIFFERENT", + "artists": [ + { + "name": "Seven Lions", + "id": "UCYd2yzYRx7b9FYnBSlbnknA" + }, + { + "name": "Jason Ross", + "id": "UCVCD9Iwnqn2ipN9JIF6B-nA" + }, + { + "name": "Crystal Skies", + "id": "UCTJZESxeZ0J_M7JXyFUVmvA" } ], - "feedbackTokens": [], - "likeStatus": "LIKE" + "album": { + "name": "Foolish Of Me", + "id": "MPREb_C8aRK1qmsDJ" + }, + "year": "2020", + "counterpart": { + "videoId": "E0S4W34zFMA", + "title": "Foolish Of Me [ABGT404] (feat. Jonathan Mendelsohn)", + "length": "3:07", + "thumbnail": [...], + "feedbackTokens": null, + "likeStatus": "LIKE", + "artists": [ + { + "name": "Jason Ross", + "id": null + }, + { + "name": "Seven Lions", + "id": null + }, + { + "name": "Crystal Skies", + "id": null + } + ], + "views": "6.6K" + } },... ], "playlistId": "RDAMVM4y33h81phKU", diff --git a/ytmusicapi/parsers/watch.py b/ytmusicapi/parsers/watch.py index 46f9057d..f6e5ba04 100644 --- a/ytmusicapi/parsers/watch.py +++ b/ytmusicapi/parsers/watch.py @@ -4,35 +4,46 @@ def parse_watch_playlist(results): tracks = [] + PPVWR = 'playlistPanelVideoWrapperRenderer' + PPVR = 'playlistPanelVideoRenderer' for result in results: - if 'playlistPanelVideoWrapperRenderer' in result: - result = result['playlistPanelVideoWrapperRenderer']['primaryRenderer'] - if 'playlistPanelVideoRenderer' not in result: + counterpart = None + if PPVWR in result: + counterpart = result[PPVWR]['counterpart'][0]['counterpartRenderer'][PPVR] + result = result[PPVWR]['primaryRenderer'] + if PPVR not in result: continue - data = result['playlistPanelVideoRenderer'] + data = result[PPVR] if 'unplayableText' in data: continue - feedback_tokens = like_status = None - for item in nav(data, MENU_ITEMS): - if TOGGLE_MENU in item: - service = item[TOGGLE_MENU]['defaultServiceEndpoint'] - if 'feedbackEndpoint' in service: - feedback_tokens = parse_song_menu_tokens(item) - if 'likeEndpoint' in service: - like_status = parse_like_status(service) - - song_info = parse_song_runs(data['longBylineText']['runs']) - - track = { - 'videoId': data['videoId'], - 'title': nav(data, TITLE_TEXT), - 'length': nav(data, ['lengthText', 'runs', 0, 'text'], True), - 'thumbnail': nav(data, THUMBNAIL), - 'feedbackTokens': feedback_tokens, - 'likeStatus': like_status - } - track.update(song_info) + track = parse_watch_track(data) + if counterpart: + track['counterpart'] = parse_watch_track(counterpart) tracks.append(track) return tracks + + +def parse_watch_track(data): + feedback_tokens = like_status = None + for item in nav(data, MENU_ITEMS): + if TOGGLE_MENU in item: + service = item[TOGGLE_MENU]['defaultServiceEndpoint'] + if 'feedbackEndpoint' in service: + feedback_tokens = parse_song_menu_tokens(item) + if 'likeEndpoint' in service: + like_status = parse_like_status(service) + + song_info = parse_song_runs(data['longBylineText']['runs']) + + track = { + 'videoId': data['videoId'], + 'title': nav(data, TITLE_TEXT), + 'length': nav(data, ['lengthText', 'runs', 0, 'text'], True), + 'thumbnail': nav(data, THUMBNAIL), + 'feedbackTokens': feedback_tokens, + 'likeStatus': like_status + } + track.update(song_info) + return track From 2b6ced5226a718dff3ba43a59110fcd80ed17803 Mon Sep 17 00:00:00 2001 From: Lucas Macedo Date: Tue, 22 Mar 2022 22:56:21 -0300 Subject: [PATCH 043/238] get videoType from video results --- ytmusicapi/parsers/__init__.py | 3 +++ ytmusicapi/parsers/browsing.py | 1 + 2 files changed, 4 insertions(+) diff --git a/ytmusicapi/parsers/__init__.py b/ytmusicapi/parsers/__init__.py index 390b1bbf..f89fe329 100644 --- a/ytmusicapi/parsers/__init__.py +++ b/ytmusicapi/parsers/__init__.py @@ -24,6 +24,9 @@ NAVIGATION_VIDEO_ID = ['navigationEndpoint', 'watchEndpoint', 'videoId'] NAVIGATION_PLAYLIST_ID = ['navigationEndpoint', 'watchEndpoint', 'playlistId'] NAVIGATION_WATCH_PLAYLIST_ID = ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'] +NAVIGATION_VIDEO_TYPE = [ + 'playNavigationEndpoint', 'watchEndpoint', 'watchEndpointMusicSupportedConfigs', 'watchEndpointMusicConfig', 'musicVideoType' +] HEADER_DETAIL = ['header', 'musicDetailHeaderRenderer'] DESCRIPTION = ['description'] + RUN_TEXT CAROUSEL = ['musicCarouselShelfRenderer'] diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index e8b88cde..5fed26aa 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -107,6 +107,7 @@ def parse_search_results(self, results, resultType=None, category=None): elif resultType == 'video': search_result['views'] = None + search_result['videoType'] = nav(data, PLAY_BUTTON + NAVIGATION_VIDEO_TYPE, True) elif resultType == 'upload': browse_id = nav(data, NAVIGATION_BROWSE_ID, True) From 33232aef0c668efae4ceb7a93ca57db5688f3f23 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 23 Mar 2022 18:29:50 +0100 Subject: [PATCH 044/238] search: add videoType to docs --- ytmusicapi/mixins/browsing.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 30fd94da..4b876fe8 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -155,6 +155,7 @@ def search(self, } ], "views": "1.4M", + "videoType": "MUSIC_VIDEO_TYPE_OMV", "duration": "4:38", "duration_seconds": 278 }, From 56b266dbdb32abda9c8e9421a3c42f625d33cb37 Mon Sep 17 00:00:00 2001 From: Lucas Macedo Date: Thu, 24 Mar 2022 12:20:55 -0300 Subject: [PATCH 045/238] return video type on get watch playlist --- ytmusicapi/parsers/__init__.py | 2 +- ytmusicapi/parsers/browsing.py | 3 +++ ytmusicapi/parsers/watch.py | 3 ++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ytmusicapi/parsers/__init__.py b/ytmusicapi/parsers/__init__.py index f89fe329..e991279c 100644 --- a/ytmusicapi/parsers/__init__.py +++ b/ytmusicapi/parsers/__init__.py @@ -25,7 +25,7 @@ NAVIGATION_PLAYLIST_ID = ['navigationEndpoint', 'watchEndpoint', 'playlistId'] NAVIGATION_WATCH_PLAYLIST_ID = ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'] NAVIGATION_VIDEO_TYPE = [ - 'playNavigationEndpoint', 'watchEndpoint', 'watchEndpointMusicSupportedConfigs', 'watchEndpointMusicConfig', 'musicVideoType' + 'watchEndpoint', 'watchEndpointMusicSupportedConfigs', 'watchEndpointMusicConfig', 'musicVideoType' ] HEADER_DETAIL = ['header', 'musicDetailHeaderRenderer'] DESCRIPTION = ['description'] + RUN_TEXT diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 5fed26aa..cdfa545e 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -144,6 +144,9 @@ def parse_search_results(self, results, resultType=None, category=None): search_result['videoId'] = nav( data, PLAY_BUTTON + ['playNavigationEndpoint', 'watchEndpoint', 'videoId'], True) + search_result['videoType'] = nav( + data, PLAY_BUTTON + ['playNavigationEndpoint'] + NAVIGATION_VIDEO_TYPE, + True) if resultType in ['song', 'video', 'album']: search_result['duration'] = None diff --git a/ytmusicapi/parsers/watch.py b/ytmusicapi/parsers/watch.py index f6e5ba04..55e83584 100644 --- a/ytmusicapi/parsers/watch.py +++ b/ytmusicapi/parsers/watch.py @@ -43,7 +43,8 @@ def parse_watch_track(data): 'length': nav(data, ['lengthText', 'runs', 0, 'text'], True), 'thumbnail': nav(data, THUMBNAIL), 'feedbackTokens': feedback_tokens, - 'likeStatus': like_status + 'likeStatus': like_status, + 'videoType': nav(data, ['navigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) } track.update(song_info) return track From 81d3799c6a1c750bc70aafce595b33577f8bc02a Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 7 Apr 2022 17:40:11 +0200 Subject: [PATCH 046/238] get_watch_playlist: add videoType to docs --- ytmusicapi/mixins/watch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index 190b8512..9d4b0aa9 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -44,6 +44,7 @@ def get_watch_playlist(self, "remove": "AB9zfpJdzWLcdZtC..." }, "likeStatus": "INDIFFERENT", + "videoType": "MUSIC_VIDEO_TYPE_ATV", "artists": [ { "name": "Seven Lions", From d6f8d5dc78b9f7b0559f9d3f1b0383a37811f7a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonah=20Br=C3=BCchert?= Date: Fri, 8 Apr 2022 02:05:03 +0200 Subject: [PATCH 047/238] get_artist: Don't fail on channels with only one category of content The current check is meant to detect channels with only video content, but it also hits small bands that for example have only released singles so far. An example for this is "UC1uK8RT3m4nNpXmOSsFsZeg". Since failing is not really helpful anyway (for example in audiotube, it could still play only the sound of a channel that contains only videos), I propose to remove the check altogether. --- ytmusicapi/mixins/browsing.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 4b876fe8..2a66646d 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -393,10 +393,6 @@ def get_artist(self, channelId: str) -> Dict: response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) - if len(results) == 1: - # not a YouTube Music Channel, a standard YouTube Channel ID with no music content was given - raise ValueError(f"The YouTube Channel {channelId} has no music content.") - artist = {'description': None, 'views': None} header = response['header']['musicImmersiveHeaderRenderer'] artist['name'] = nav(header, TITLE_TEXT) From 2b44cbe64d817cae22076c4e4f38c5f6e9cc10f7 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 14 Apr 2022 17:27:13 +0200 Subject: [PATCH 048/238] get_watch_playlist: add related content browseId --- ytmusicapi/mixins/watch.py | 11 ++++++----- ytmusicapi/parsers/watch.py | 8 ++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index 9d4b0aa9..6bfe4e1d 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -120,10 +120,8 @@ def get_watch_playlist(self, 'watchNextTabbedResultsRenderer' ]) - lyrics_browse_id = None - if 'unselectable' not in watchNextRenderer['tabs'][1]['tabRenderer']: - lyrics_browse_id = watchNextRenderer['tabs'][1]['tabRenderer']['endpoint'][ - 'browseEndpoint']['browseId'] + lyrics_browse_id = get_tab_browse_id(watchNextRenderer, 1) + related_browse_id = get_tab_browse_id(watchNextRenderer, 2) results = nav(watchNextRenderer, TAB_CONTENT + ['musicQueueRenderer', 'content', 'playlistPanelRenderer']) @@ -143,7 +141,10 @@ def get_watch_playlist(self, get_continuations(results, 'playlistPanelContinuation', limit - len(tracks), request_func, parse_func, '' if is_playlist else 'Radio')) - return dict(tracks=tracks, playlistId=playlist, lyrics=lyrics_browse_id) + return dict(tracks=tracks, + playlistId=playlist, + lyrics=lyrics_browse_id, + related=related_browse_id) def get_watch_playlist_shuffle(self, videoId: str = None, diff --git a/ytmusicapi/parsers/watch.py b/ytmusicapi/parsers/watch.py index 55e83584..c27f9f6f 100644 --- a/ytmusicapi/parsers/watch.py +++ b/ytmusicapi/parsers/watch.py @@ -48,3 +48,11 @@ def parse_watch_track(data): } track.update(song_info) return track + + +def get_tab_browse_id(watchNextRenderer, tab_id): + if 'unselectable' not in watchNextRenderer['tabs'][tab_id]['tabRenderer']: + return watchNextRenderer['tabs'][tab_id]['tabRenderer']['endpoint']['browseEndpoint'][ + 'browseId'] + else: + return None From 1d30910e23f24a77755a46c59f14382bebb32eb5 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 14 Apr 2022 17:33:14 +0200 Subject: [PATCH 049/238] remove test invalidated by get_artist changes (#181, #267) --- tests/test.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/test.py b/tests/test.py index 7fe3a6b1..bcb68870 100644 --- a/tests/test.py +++ b/tests/test.py @@ -99,11 +99,6 @@ def test_get_artist(self): results = self.yt.get_artist("UCLZ7tlKC06ResyDmEStSrOw") # no album year self.assertGreaterEqual(len(results), 11) - def test_get_artist_for_non_youtube_music_channel(self): - # all YouTube channel IDs can be looked up in YouTube Music, but the page they return will not necessarily return any music content - non_music_channel_id = "UCUcpVoi5KkJmnE3bvEhHR0Q" # e.g. https://music.youtube.com/channel/UCUcpVoi5KkJmnE3bvEhHR0Q - self.assertRaises(ValueError, self.yt.get_artist, non_music_channel_id) - def test_get_artist_albums(self): artist = self.yt.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") results = self.yt.get_artist_albums(artist['albums']['browseId'], From f4ff4ba2875e51aa3931aa41bf5cd6d3b7474300 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 5 May 2022 14:26:45 +0200 Subject: [PATCH 050/238] refactor helpers and utils, extract SearchMixin from BrowsingMixin --- .../{parsers/utils.py => continuations.py} | 98 +-------- ytmusicapi/helpers.py | 63 +----- ytmusicapi/mixins/_utils.py | 40 ++++ ytmusicapi/mixins/browsing.py | 195 +----------------- ytmusicapi/mixins/library.py | 4 +- ytmusicapi/mixins/playlists.py | 9 +- ytmusicapi/mixins/search.py | 192 +++++++++++++++++ ytmusicapi/mixins/uploads.py | 13 +- ytmusicapi/mixins/watch.py | 3 + .../{parsers/__init__.py => navigation.py} | 35 +++- ytmusicapi/parsers/_utils.py | 80 +++++++ ytmusicapi/parsers/albums.py | 8 +- ytmusicapi/parsers/browsing.py | 6 +- ytmusicapi/parsers/library.py | 11 +- ytmusicapi/parsers/playlists.py | 9 +- ytmusicapi/parsers/songs.py | 3 +- ytmusicapi/parsers/uploads.py | 4 +- ytmusicapi/parsers/watch.py | 2 +- ytmusicapi/ytmusic.py | 3 +- 19 files changed, 400 insertions(+), 378 deletions(-) rename ytmusicapi/{parsers/utils.py => continuations.py} (52%) create mode 100644 ytmusicapi/mixins/_utils.py create mode 100644 ytmusicapi/mixins/search.py rename ytmusicapi/{parsers/__init__.py => navigation.py} (77%) create mode 100644 ytmusicapi/parsers/_utils.py diff --git a/ytmusicapi/parsers/utils.py b/ytmusicapi/continuations.py similarity index 52% rename from ytmusicapi/parsers/utils.py rename to ytmusicapi/continuations.py index 5f46f687..aedd8d53 100644 --- a/ytmusicapi/parsers/utils.py +++ b/ytmusicapi/continuations.py @@ -1,55 +1,4 @@ -from . import * - - -def parse_menu_playlists(data, result): - watch_menu = find_objects_by_key(nav(data, MENU_ITEMS), 'menuNavigationItemRenderer') - for item in [_x['menuNavigationItemRenderer'] for _x in watch_menu]: - icon = nav(item, ['icon', 'iconType']) - if icon == 'MUSIC_SHUFFLE': - watch_key = 'shuffleId' - elif icon == 'MIX': - watch_key = 'radioId' - else: - continue - - watch_id = nav(item, ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'], True) - if not watch_id: - watch_id = nav(item, ['navigationEndpoint', 'watchEndpoint', 'playlistId'], True) - if watch_id: - result[watch_key] = watch_id - - -def get_item_text(item, index, run_index=0, none_if_absent=False): - column = get_flex_column_item(item, index) - if not column: - return None - if none_if_absent and len(column['text']['runs']) < run_index + 1: - return None - return column['text']['runs'][run_index]['text'] - - -def get_flex_column_item(item, index): - if len(item['flexColumns']) <= index or \ - 'text' not in item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer'] or \ - 'runs' not in item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer']['text']: - return None - - return item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer'] - - -def get_fixed_column_item(item, index): - if 'text' not in item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer'] or \ - 'runs' not in item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer']['text']: - return None - - return item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer'] - - -def get_browse_id(item, index): - if 'navigationEndpoint' not in item['text']['runs'][index]: - return None - else: - return nav(item['text']['runs'][index], NAVIGATION_BROWSE_ID) +from ytmusicapi.navigation import nav def get_continuations(results, continuation_type, limit, request_func, parse_func, ctoken_path=""): @@ -136,48 +85,3 @@ def validate_response(response, per_page, limit, current_count): # response is invalid, if it has less items then minimal expected count return len(response['parsed']) >= expected_items_count - - -def validate_playlist_id(playlistId): - return playlistId if not playlistId.startswith("VL") else playlistId[2:] - - -def nav(root, items, none_if_absent=False): - """Access a nested object in root by item sequence.""" - try: - for k in items: - root = root[k] - return root - except Exception as err: - if none_if_absent: - return None - else: - raise err - - -def find_object_by_key(object_list, key, nested=None, is_key=False): - for item in object_list: - if nested: - item = item[nested] - if key in item: - return item[key] if is_key else item - return None - - -def find_objects_by_key(object_list, key, nested=None): - objects = [] - for item in object_list: - if nested: - item = item[nested] - if key in item: - objects.append(item) - return objects - - -def get_dot_separator_index(runs): - index = len(runs) - try: - index = runs.index({'text': ' • '}) - except ValueError: - len(runs) - return index diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index ac6c0158..03d43ef6 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -3,39 +3,10 @@ from http.cookies import SimpleCookie from hashlib import sha1 import time -from datetime import date -from functools import wraps import locale from ytmusicapi.constants import * -def prepare_like_endpoint(rating): - if rating == 'LIKE': - return 'like/like' - elif rating == 'DISLIKE': - return 'like/dislike' - elif rating == 'INDIFFERENT': - return 'like/removelike' - else: - return None - - -def validate_order_parameter(order): - orders = ['a_to_z', 'z_to_a', 'recently_added'] - if order and order not in orders: - raise Exception( - "Invalid order provided. Please use one of the following orders or leave out the parameter: " - + ', '.join(orders)) - - -def prepare_order_params(order): - orders = ['a_to_z', 'z_to_a', 'recently_added'] - if order is not None: - # determine order_params via `.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[1].itemSectionRenderer.header.itemSectionTabbedHeaderRenderer.endItems[1].dropdownRenderer.entries[].dropdownItemRenderer.onSelectCommand.browseEndpoint.params` of `/youtubei/v1/browse` response - order_params = ['ggMGKgQIARAA', 'ggMGKgQIARAB', 'ggMGKgQIABAB'] - return order_params[orders.index(order)] - - def initialize_headers(): return { "user-agent": USER_AGENT, @@ -69,13 +40,6 @@ def get_visitor_id(request_func): return {'X-Goog-Visitor-Id': visitor_id} -def html_to_txt(html_text): - tags = re.findall("<[^>]+>", html_text) - for tag in tags: - html_text = html_text.replace(tag, '') - return html_text - - def sapisid_from_cookie(raw_cookie): cookie = SimpleCookie() cookie.load(raw_cookie) @@ -91,10 +55,6 @@ def get_authorization(auth): return "SAPISIDHASH " + unix_timestamp + "_" + sha_1.hexdigest() -def get_datestamp(): - return (date.today() - date.fromtimestamp(0)).days - - def to_int(string): number_string = re.sub('[^\\d]', '', string) try: @@ -105,24 +65,7 @@ def to_int(string): return int_value -def parse_duration(duration): - if duration is None: - return duration - mapped_increments = zip([1, 60, 3600], reversed(duration.split(":"))) - seconds = sum(multiplier * int(time) for multiplier, time in mapped_increments) - return seconds - - def sum_total_duration(item): - return sum( - [track['duration_seconds'] if 'duration_seconds' in track else 0 for track in item['tracks']] - ) - - -def i18n(method): - @wraps(method) - def _impl(self, *method_args, **method_kwargs): - method.__globals__['_'] = self.lang.gettext - return method(self, *method_args, **method_kwargs) - - return _impl + return sum([ + track['duration_seconds'] if 'duration_seconds' in track else 0 for track in item['tracks'] + ]) diff --git a/ytmusicapi/mixins/_utils.py b/ytmusicapi/mixins/_utils.py new file mode 100644 index 00000000..a0c6d4cb --- /dev/null +++ b/ytmusicapi/mixins/_utils.py @@ -0,0 +1,40 @@ +import re +from datetime import date + + +def prepare_like_endpoint(rating): + if rating == 'LIKE': + return 'like/like' + elif rating == 'DISLIKE': + return 'like/dislike' + elif rating == 'INDIFFERENT': + return 'like/removelike' + else: + return None + + +def validate_order_parameter(order): + orders = ['a_to_z', 'z_to_a', 'recently_added'] + if order and order not in orders: + raise Exception( + "Invalid order provided. Please use one of the following orders or leave out the parameter: " + + ', '.join(orders)) + + +def prepare_order_params(order): + orders = ['a_to_z', 'z_to_a', 'recently_added'] + if order is not None: + # determine order_params via `.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[1].itemSectionRenderer.header.itemSectionTabbedHeaderRenderer.endItems[1].dropdownRenderer.entries[].dropdownItemRenderer.onSelectCommand.browseEndpoint.params` of `/youtubei/v1/browse` response + order_params = ['ggMGKgQIARAA', 'ggMGKgQIARAB', 'ggMGKgQIABAB'] + return order_params[orders.index(order)] + + +def html_to_txt(html_text): + tags = re.findall("<[^>]+>", html_text) + for tag in tags: + html_text = html_text.replace(tag, '') + return html_text + + +def get_datestamp(): + return (date.today() - date.fromtimestamp(0)).days diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 2a66646d..c5c0ce08 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -1,8 +1,9 @@ -from ytmusicapi.helpers import * +from ._utils import get_datestamp +from ytmusicapi.continuations import get_continuations +from ytmusicapi.helpers import YTM_DOMAIN, sum_total_duration from ytmusicapi.parsers.browsing import * -from ytmusicapi.parsers.search_params import * -from ytmusicapi.parsers.albums import * -from ytmusicapi.parsers.playlists import * +from ytmusicapi.parsers.albums import parse_album_header +from ytmusicapi.parsers.playlists import parse_playlist_items from ytmusicapi.parsers.library import parse_albums @@ -113,192 +114,6 @@ def get_home(self, limit=3) -> List[Dict]: return home - def search(self, - query: str, - filter: str = None, - scope: str = None, - limit: int = 20, - ignore_spelling: bool = False) -> List[Dict]: - """ - Search YouTube music - Returns results within the provided category. - - :param query: Query string, i.e. 'Oasis Wonderwall' - :param filter: Filter for item types. Allowed values: ``songs``, ``videos``, ``albums``, ``artists``, ``playlists``, ``community_playlists``, ``featured_playlists``, ``uploads``. - Default: Default search, including all types of items. - :param scope: Search scope. Allowed values: ``library``, ``uploads``. - Default: Search the public YouTube Music catalogue. - :param limit: Number of search results to return - Default: 20 - :param ignore_spelling: Whether to ignore YTM spelling suggestions. - If True, the exact search term will be searched for, and will not be corrected. - This does not have any effect when the filter is set to ``uploads``. - Default: False, will use YTM's default behavior of autocorrecting the search. - :return: List of results depending on filter. - resultType specifies the type of item (important for default search). - albums, artists and playlists additionally contain a browseId, corresponding to - albumId, channelId and playlistId (browseId=``VL``+playlistId) - - Example list for default search with one result per resultType for brevity. Normally - there are 3 results per resultType and an additional ``thumbnails`` key:: - - [ - { - "category": "Top result", - "resultType": "video", - "videoId": "vU05Eksc_iM", - "title": "Wonderwall", - "artists": [ - { - "name": "Oasis", - "id": "UCmMUZbaYdNH0bEd1PAlAqsA" - } - ], - "views": "1.4M", - "videoType": "MUSIC_VIDEO_TYPE_OMV", - "duration": "4:38", - "duration_seconds": 278 - }, - { - "category": "Songs", - "resultType": "song", - "videoId": "ZrOKjDZOtkA", - "title": "Wonderwall", - "artists": [ - { - "name": "Oasis", - "id": "UCmMUZbaYdNH0bEd1PAlAqsA" - } - ], - "album": { - "name": "(What's The Story) Morning Glory? (Remastered)", - "id": "MPREb_9nqEki4ZDpp" - }, - "duration": "4:19", - "duration_seconds": 259 - "isExplicit": false, - "feedbackTokens": { - "add": null, - "remove": null - } - }, - { - "category": "Albums", - "resultType": "album", - "browseId": "MPREb_9nqEki4ZDpp", - "title": "(What's The Story) Morning Glory? (Remastered)", - "type": "Album", - "artist": "Oasis", - "year": "1995", - "isExplicit": false - }, - { - "category": "Community playlists", - "resultType": "playlist", - "browseId": "VLPLK1PkWQlWtnNfovRdGWpKffO1Wdi2kvDx", - "title": "Wonderwall - Oasis", - "author": "Tate Henderson", - "itemCount": "174" - }, - { - "category": "Videos", - "resultType": "video", - "videoId": "bx1Bh8ZvH84", - "title": "Wonderwall", - "artists": [ - { - "name": "Oasis", - "id": "UCmMUZbaYdNH0bEd1PAlAqsA" - } - ], - "views": "386M", - "duration": "4:38", - "duration_seconds": 278 - }, - { - "category": "Artists", - "resultType": "artist", - "browseId": "UCmMUZbaYdNH0bEd1PAlAqsA", - "artist": "Oasis", - "shuffleId": "RDAOkjHYJjL1a3xspEyVkhHAsg", - "radioId": "RDEMkjHYJjL1a3xspEyVkhHAsg" - } - ] - - - """ - body = {'query': query} - endpoint = 'search' - search_results = [] - filters = [ - 'albums', 'artists', 'playlists', 'community_playlists', 'featured_playlists', 'songs', - 'videos' - ] - if filter and filter not in filters: - raise Exception( - "Invalid filter provided. Please use one of the following filters or leave out the parameter: " - + ', '.join(filters)) - - scopes = ['library', 'uploads'] - if scope and scope not in scopes: - raise Exception( - "Invalid scope provided. Please use one of the following scopes or leave out the parameter: " - + ', '.join(scopes)) - - params = get_search_params(filter, scope, ignore_spelling) - if params: - body['params'] = params - - response = self._send_request(endpoint, body) - - # no results - if 'contents' not in response: - return search_results - - if 'tabbedSearchResultsRenderer' in response['contents']: - tab_index = 0 if not scope or filter else scopes.index(scope) + 1 - results = response['contents']['tabbedSearchResultsRenderer']['tabs'][tab_index][ - 'tabRenderer']['content'] - else: - results = response['contents'] - - results = nav(results, SECTION_LIST) - - # no results - if len(results) == 1 and 'itemSectionRenderer' in results: - return search_results - - # set filter for parser - if filter and 'playlists' in filter: - filter = 'playlists' - elif scope == scopes[1]: - filter = scopes[1] - - for res in results: - if 'musicShelfRenderer' in res: - results = res['musicShelfRenderer']['contents'] - original_filter = filter - category = nav(res, MUSIC_SHELF + TITLE_TEXT, True) - if not filter and scope == scopes[0]: - filter = category - - type = filter[:-1].lower() if filter else None - search_results.extend(self.parser.parse_search_results(results, type, category)) - filter = original_filter - - if 'continuations' in res['musicShelfRenderer']: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) - - parse_func = lambda contents: self.parser.parse_search_results( - contents, type, category) - - search_results.extend( - get_continuations(res['musicShelfRenderer'], 'musicShelfContinuation', - limit - len(search_results), request_func, parse_func)) - - return search_results - def get_artist(self, channelId: str) -> Dict: """ Get information about an artist and their top releases (songs, diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index 7005047b..212601df 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -1,7 +1,7 @@ -from ytmusicapi.helpers import * +from ytmusicapi.continuations import * +from ._utils import * from ytmusicapi.parsers.browsing import * from ytmusicapi.parsers.library import * -from ytmusicapi.parsers.playlists import * class LibraryMixin: diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 5fa976cd..6fb0c539 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -1,7 +1,10 @@ import unicodedata -from typing import List, Dict, Union, Tuple -from ytmusicapi.helpers import * -from ytmusicapi.parsers.utils import * +from typing import Dict, Union, Tuple +from ._utils import * + +from ytmusicapi.continuations import get_continuations, get_continuation_string +from ytmusicapi.navigation import * +from ytmusicapi.helpers import to_int, sum_total_duration from ytmusicapi.parsers.playlists import * diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py new file mode 100644 index 00000000..9164c1d3 --- /dev/null +++ b/ytmusicapi/mixins/search.py @@ -0,0 +1,192 @@ +from typing import List, Dict +from ytmusicapi.navigation import * +from ytmusicapi.continuations import get_continuations +from ytmusicapi.parsers.search_params import * + + +class SearchMixin: + def search(self, + query: str, + filter: str = None, + scope: str = None, + limit: int = 20, + ignore_spelling: bool = False) -> List[Dict]: + """ + Search YouTube music + Returns results within the provided category. + + :param query: Query string, i.e. 'Oasis Wonderwall' + :param filter: Filter for item types. Allowed values: ``songs``, ``videos``, ``albums``, ``artists``, ``playlists``, ``community_playlists``, ``featured_playlists``, ``uploads``. + Default: Default search, including all types of items. + :param scope: Search scope. Allowed values: ``library``, ``uploads``. + Default: Search the public YouTube Music catalogue. + :param limit: Number of search results to return + Default: 20 + :param ignore_spelling: Whether to ignore YTM spelling suggestions. + If True, the exact search term will be searched for, and will not be corrected. + This does not have any effect when the filter is set to ``uploads``. + Default: False, will use YTM's default behavior of autocorrecting the search. + :return: List of results depending on filter. + resultType specifies the type of item (important for default search). + albums, artists and playlists additionally contain a browseId, corresponding to + albumId, channelId and playlistId (browseId=``VL``+playlistId) + + Example list for default search with one result per resultType for brevity. Normally + there are 3 results per resultType and an additional ``thumbnails`` key:: + + [ + { + "category": "Top result", + "resultType": "video", + "videoId": "vU05Eksc_iM", + "title": "Wonderwall", + "artists": [ + { + "name": "Oasis", + "id": "UCmMUZbaYdNH0bEd1PAlAqsA" + } + ], + "views": "1.4M", + "videoType": "MUSIC_VIDEO_TYPE_OMV", + "duration": "4:38", + "duration_seconds": 278 + }, + { + "category": "Songs", + "resultType": "song", + "videoId": "ZrOKjDZOtkA", + "title": "Wonderwall", + "artists": [ + { + "name": "Oasis", + "id": "UCmMUZbaYdNH0bEd1PAlAqsA" + } + ], + "album": { + "name": "(What's The Story) Morning Glory? (Remastered)", + "id": "MPREb_9nqEki4ZDpp" + }, + "duration": "4:19", + "duration_seconds": 259 + "isExplicit": false, + "feedbackTokens": { + "add": null, + "remove": null + } + }, + { + "category": "Albums", + "resultType": "album", + "browseId": "MPREb_9nqEki4ZDpp", + "title": "(What's The Story) Morning Glory? (Remastered)", + "type": "Album", + "artist": "Oasis", + "year": "1995", + "isExplicit": false + }, + { + "category": "Community playlists", + "resultType": "playlist", + "browseId": "VLPLK1PkWQlWtnNfovRdGWpKffO1Wdi2kvDx", + "title": "Wonderwall - Oasis", + "author": "Tate Henderson", + "itemCount": "174" + }, + { + "category": "Videos", + "resultType": "video", + "videoId": "bx1Bh8ZvH84", + "title": "Wonderwall", + "artists": [ + { + "name": "Oasis", + "id": "UCmMUZbaYdNH0bEd1PAlAqsA" + } + ], + "views": "386M", + "duration": "4:38", + "duration_seconds": 278 + }, + { + "category": "Artists", + "resultType": "artist", + "browseId": "UCmMUZbaYdNH0bEd1PAlAqsA", + "artist": "Oasis", + "shuffleId": "RDAOkjHYJjL1a3xspEyVkhHAsg", + "radioId": "RDEMkjHYJjL1a3xspEyVkhHAsg" + } + ] + + + """ + body = {'query': query} + endpoint = 'search' + search_results = [] + filters = [ + 'albums', 'artists', 'playlists', 'community_playlists', 'featured_playlists', 'songs', + 'videos' + ] + if filter and filter not in filters: + raise Exception( + "Invalid filter provided. Please use one of the following filters or leave out the parameter: " + + ', '.join(filters)) + + scopes = ['library', 'uploads'] + if scope and scope not in scopes: + raise Exception( + "Invalid scope provided. Please use one of the following scopes or leave out the parameter: " + + ', '.join(scopes)) + + params = get_search_params(filter, scope, ignore_spelling) + if params: + body['params'] = params + + response = self._send_request(endpoint, body) + + # no results + if 'contents' not in response: + return search_results + + if 'tabbedSearchResultsRenderer' in response['contents']: + tab_index = 0 if not scope or filter else scopes.index(scope) + 1 + results = response['contents']['tabbedSearchResultsRenderer']['tabs'][tab_index][ + 'tabRenderer']['content'] + else: + results = response['contents'] + + results = nav(results, SECTION_LIST) + + # no results + if len(results) == 1 and 'itemSectionRenderer' in results: + return search_results + + # set filter for parser + if filter and 'playlists' in filter: + filter = 'playlists' + elif scope == scopes[1]: + filter = scopes[1] + + for res in results: + if 'musicShelfRenderer' in res: + results = res['musicShelfRenderer']['contents'] + original_filter = filter + category = nav(res, MUSIC_SHELF + TITLE_TEXT, True) + if not filter and scope == scopes[0]: + filter = category + + type = filter[:-1].lower() if filter else None + search_results.extend(self.parser.parse_search_results(results, type, category)) + filter = original_filter + + if 'continuations' in res['musicShelfRenderer']: + request_func = lambda additionalParams: self._send_request( + endpoint, body, additionalParams) + + parse_func = lambda contents: self.parser.parse_search_results( + contents, type, category) + + search_results.extend( + get_continuations(res['musicShelfRenderer'], 'musicShelfContinuation', + limit - len(search_results), request_func, parse_func)) + + return search_results diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index a71f2466..7eb3e12d 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -2,10 +2,14 @@ import ntpath import os from typing import List, Dict, Union + +from ._utils import validate_order_parameter, prepare_order_params from ytmusicapi.helpers import * -from ytmusicapi.parsers.library import * -from ytmusicapi.parsers.albums import * -from ytmusicapi.parsers.uploads import * +from ytmusicapi.navigation import * +from ytmusicapi.continuations import get_continuations +from ytmusicapi.parsers.library import parse_library_albums, parse_library_artists +from ytmusicapi.parsers.albums import parse_album_header +from ytmusicapi.parsers.uploads import parse_uploaded_items class UploadsMixin: @@ -205,7 +209,8 @@ def upload_song(self, filepath: str) -> Union[str, requests.Response]: + ', '.join(supported_filetypes)) headers = self.headers.copy() - upload_url = "https://upload.youtube.com/upload/usermusic/http?authuser=%s" % headers['x-goog-authuser'] + upload_url = "https://upload.youtube.com/upload/usermusic/http?authuser=%s" % headers[ + 'x-goog-authuser'] filesize = os.path.getsize(filepath) body = ("filename=" + ntpath.basename(filepath)).encode('utf-8') headers.pop('content-encoding', None) diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index 6bfe4e1d..67452ee7 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -1,4 +1,7 @@ from typing import List, Dict, Union + +from ytmusicapi.continuations import get_continuations +from ytmusicapi.parsers.playlists import validate_playlist_id from ytmusicapi.parsers.watch import * diff --git a/ytmusicapi/parsers/__init__.py b/ytmusicapi/navigation.py similarity index 77% rename from ytmusicapi/parsers/__init__.py rename to ytmusicapi/navigation.py index e991279c..4323ac11 100644 --- a/ytmusicapi/parsers/__init__.py +++ b/ytmusicapi/navigation.py @@ -25,7 +25,8 @@ NAVIGATION_PLAYLIST_ID = ['navigationEndpoint', 'watchEndpoint', 'playlistId'] NAVIGATION_WATCH_PLAYLIST_ID = ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'] NAVIGATION_VIDEO_TYPE = [ - 'watchEndpoint', 'watchEndpointMusicSupportedConfigs', 'watchEndpointMusicConfig', 'musicVideoType' + 'watchEndpoint', 'watchEndpointMusicSupportedConfigs', 'watchEndpointMusicConfig', + 'musicVideoType' ] HEADER_DETAIL = ['header', 'musicDetailHeaderRenderer'] DESCRIPTION = ['description'] + RUN_TEXT @@ -54,3 +55,35 @@ CATEGORY_PARAMS = ['musicNavigationButtonRenderer', 'clickCommand', 'browseEndpoint', 'params'] MRLIR = 'musicResponsiveListItemRenderer' MTRIR = 'musicTwoRowItemRenderer' + + +def nav(root, items, none_if_absent=False): + """Access a nested object in root by item sequence.""" + try: + for k in items: + root = root[k] + return root + except Exception as err: + if none_if_absent: + return None + else: + raise err + + +def find_object_by_key(object_list, key, nested=None, is_key=False): + for item in object_list: + if nested: + item = item[nested] + if key in item: + return item[key] if is_key else item + return None + + +def find_objects_by_key(object_list, key, nested=None): + objects = [] + for item in object_list: + if nested: + item = item[nested] + if key in item: + objects.append(item) + return objects diff --git a/ytmusicapi/parsers/_utils.py b/ytmusicapi/parsers/_utils.py new file mode 100644 index 00000000..1200cd50 --- /dev/null +++ b/ytmusicapi/parsers/_utils.py @@ -0,0 +1,80 @@ +from functools import wraps + +from ytmusicapi.navigation import * + + +def parse_menu_playlists(data, result): + watch_menu = find_objects_by_key(nav(data, MENU_ITEMS), 'menuNavigationItemRenderer') + for item in [_x['menuNavigationItemRenderer'] for _x in watch_menu]: + icon = nav(item, ['icon', 'iconType']) + if icon == 'MUSIC_SHUFFLE': + watch_key = 'shuffleId' + elif icon == 'MIX': + watch_key = 'radioId' + else: + continue + + watch_id = nav(item, ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'], True) + if not watch_id: + watch_id = nav(item, ['navigationEndpoint', 'watchEndpoint', 'playlistId'], True) + if watch_id: + result[watch_key] = watch_id + + +def get_item_text(item, index, run_index=0, none_if_absent=False): + column = get_flex_column_item(item, index) + if not column: + return None + if none_if_absent and len(column['text']['runs']) < run_index + 1: + return None + return column['text']['runs'][run_index]['text'] + + +def get_flex_column_item(item, index): + if len(item['flexColumns']) <= index or \ + 'text' not in item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer'] or \ + 'runs' not in item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer']['text']: + return None + + return item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer'] + + +def get_fixed_column_item(item, index): + if 'text' not in item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer'] or \ + 'runs' not in item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer']['text']: + return None + + return item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer'] + + +def get_browse_id(item, index): + if 'navigationEndpoint' not in item['text']['runs'][index]: + return None + else: + return nav(item['text']['runs'][index], NAVIGATION_BROWSE_ID) + + +def get_dot_separator_index(runs): + index = len(runs) + try: + index = runs.index({'text': ' • '}) + except ValueError: + len(runs) + return index + + +def parse_duration(duration): + if duration is None: + return duration + mapped_increments = zip([1, 60, 3600], reversed(duration.split(":"))) + seconds = sum(multiplier * int(time) for multiplier, time in mapped_increments) + return seconds + + +def i18n(method): + @wraps(method) + def _impl(self, *method_args, **method_kwargs): + method.__globals__['_'] = self.lang.gettext + return method(self, *method_args, **method_kwargs) + + return _impl diff --git a/ytmusicapi/parsers/albums.py b/ytmusicapi/parsers/albums.py index 84c35cfc..3c9201d3 100644 --- a/ytmusicapi/parsers/albums.py +++ b/ytmusicapi/parsers/albums.py @@ -1,4 +1,4 @@ -from .utils import * +from ._utils import * from ytmusicapi.helpers import to_int from .songs import parse_song_runs, parse_like_status @@ -25,9 +25,11 @@ def parse_album_header(response): # add to library/uploaded menu = nav(header, MENU) toplevel = menu['topLevelButtons'] - album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID, True) + album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID, + True) if not album['audioPlaylistId']: - album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_PLAYLIST_ID, True) + album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_PLAYLIST_ID, + True) service = nav(toplevel, [1, 'buttonRenderer', 'defaultServiceEndpoint'], True) if service: album['likeStatus'] = parse_like_status(service) diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index cdfa545e..6cb4054b 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -1,7 +1,6 @@ from typing import List, Dict -from .utils import * from .songs import * -from ytmusicapi.helpers import i18n +from ._utils import * class Parser: @@ -145,8 +144,7 @@ def parse_search_results(self, results, resultType=None, category=None): data, PLAY_BUTTON + ['playNavigationEndpoint', 'watchEndpoint', 'videoId'], True) search_result['videoType'] = nav( - data, PLAY_BUTTON + ['playNavigationEndpoint'] + NAVIGATION_VIDEO_TYPE, - True) + data, PLAY_BUTTON + ['playNavigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) if resultType in ['song', 'video', 'album']: search_result['duration'] = None diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index 8e32b608..e1e51623 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -1,5 +1,6 @@ from .playlists import parse_playlist_items -from .utils import * +from ._utils import * +from ytmusicapi.continuations import get_continuations def parse_artists(results, uploaded=False): @@ -48,7 +49,7 @@ def parse_albums(results): album['browseId'] = nav(data, TITLE + NAVIGATION_BROWSE_ID) album['title'] = nav(data, TITLE_TEXT) album['thumbnails'] = nav(data, THUMBNAIL_RENDERER) - + if 'runs' in data['subtitle']: run_count = len(data['subtitle']['runs']) has_artists = False @@ -56,17 +57,17 @@ def parse_albums(results): album['year'] = nav(data, SUBTITLE) else: album['type'] = nav(data, SUBTITLE) - + if run_count == 3: if nav(data, SUBTITLE2).isdigit(): album['year'] = nav(data, SUBTITLE2) else: has_artists = True - + elif run_count > 3: album['year'] = nav(data, SUBTITLE3) has_artists = True - + if has_artists: subtitle = data['subtitle']['runs'][2] album['artists'] = [] diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index 45b5baf5..c538e63d 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -1,7 +1,6 @@ -from .utils import * -from ..helpers import parse_duration -from .songs import * from typing import List +from .songs import * +from ._utils import * def parse_playlist_items(results, menu_entries: List[List] = None): @@ -94,3 +93,7 @@ def parse_playlist_items(results, menu_entries: List[List] = None): print("Item " + str(count) + ": " + str(e)) return songs + + +def validate_playlist_id(playlistId): + return playlistId if not playlistId.startswith("VL") else playlistId[2:] diff --git a/ytmusicapi/parsers/songs.py b/ytmusicapi/parsers/songs.py index e54dbbb5..a8cfc153 100644 --- a/ytmusicapi/parsers/songs.py +++ b/ytmusicapi/parsers/songs.py @@ -1,5 +1,4 @@ -from .utils import * -from ..helpers import parse_duration +from ._utils import * import re diff --git a/ytmusicapi/parsers/uploads.py b/ytmusicapi/parsers/uploads.py index b51fac17..2c7e11ac 100644 --- a/ytmusicapi/parsers/uploads.py +++ b/ytmusicapi/parsers/uploads.py @@ -1,5 +1,5 @@ -from .utils import * -from .songs import * +from ._utils import * +from .songs import parse_song_artists, parse_song_album def parse_uploaded_items(results): diff --git a/ytmusicapi/parsers/watch.py b/ytmusicapi/parsers/watch.py index c27f9f6f..dbd3a034 100644 --- a/ytmusicapi/parsers/watch.py +++ b/ytmusicapi/parsers/watch.py @@ -1,4 +1,4 @@ -from .utils import * +from ._utils import * from .songs import * diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index b34d4077..ec5cd1bf 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -9,6 +9,7 @@ from ytmusicapi.parsers import browsing from ytmusicapi.setup import setup from ytmusicapi.mixins.browsing import BrowsingMixin +from ytmusicapi.mixins.search import SearchMixin from ytmusicapi.mixins.watch import WatchMixin from ytmusicapi.mixins.explore import ExploreMixin from ytmusicapi.mixins.library import LibraryMixin @@ -16,7 +17,7 @@ from ytmusicapi.mixins.uploads import UploadsMixin -class YTMusic(BrowsingMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, UploadsMixin): +class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, UploadsMixin): """ Allows automated interactions with YouTube Music by emulating the YouTube web client's requests. Permits both authenticated and non-authenticated requests. From 7c242ba40b2407311bafb7104d05f938f4b0752b Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 5 May 2022 15:53:08 +0200 Subject: [PATCH 051/238] add consent cookie to avoid consent page trigger (could cause unauthenticated requests to fail) --- ytmusicapi/ytmusic.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index ec5cd1bf..fef81993 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -17,7 +17,8 @@ from ytmusicapi.mixins.uploads import UploadsMixin -class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, UploadsMixin): +class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, + UploadsMixin): """ Allows automated interactions with YouTube Music by emulating the YouTube web client's requests. Permits both authenticated and non-authenticated requests. @@ -74,6 +75,7 @@ def __init__(self, self._session = requests.api self.proxies = proxies + self.cookies = {'CONSENT': 'YES+1'} # prepare headers if auth: @@ -110,9 +112,7 @@ def __init__(self, except locale.Error: with suppress(locale.Error): locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') - self.lang = gettext.translation('base', - localedir=locale_dir, - languages=[language]) + self.lang = gettext.translation('base', localedir=locale_dir, languages=[language]) self.parser = browsing.Parser(self.lang) if user: @@ -134,7 +134,8 @@ def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") - response = self._session.post(YTM_BASE_API + endpoint + YTM_PARAMS + additionalParams, json=body, headers=self.headers, - proxies=self.proxies) + proxies=self.proxies, + cookies=self.cookies) response_text = json.loads(response.text) if response.status_code >= 400: message = "Server returned HTTP " + str( @@ -144,7 +145,11 @@ def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") - return response_text def _send_get_request(self, url: str, params: Dict = None): - response = self._session.get(url, params=params, headers=self.headers, proxies=self.proxies) + response = self._session.get(url, + params=params, + headers=self.headers, + proxies=self.proxies, + cookies=self.cookies) return response.text def _check_auth(self): From edb963766c9f6bccbc55f6459560629be872633f Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 10 May 2022 14:12:16 +0200 Subject: [PATCH 052/238] feature: add get_song_related (closes #252) --- docs/source/reference.rst | 1 + tests/test.py | 5 ++ ytmusicapi/mixins/browsing.py | 95 +++++++++++++++++++++++++++++++--- ytmusicapi/navigation.py | 2 + ytmusicapi/parsers/browsing.py | 92 ++++++++++++++++++-------------- ytmusicapi/parsers/explore.py | 18 +------ 6 files changed, 149 insertions(+), 64 deletions(-) diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 8af312fa..e373f5b8 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -29,6 +29,7 @@ Browsing .. automethod:: YTMusic.get_user .. automethod:: YTMusic.get_user_playlists .. automethod:: YTMusic.get_song +.. automethod:: YTMusic.get_song_related .. automethod:: YTMusic.get_lyrics diff --git a/tests/test.py b/tests/test.py index bcb68870..b6356178 100644 --- a/tests/test.py +++ b/tests/test.py @@ -139,6 +139,11 @@ def test_get_song(self): song = self.yt.get_song(sample_video) self.assertGreaterEqual(len(song['streamingData']['adaptiveFormats']), 10) + def test_get_song_related_content(self): + song = self.yt_auth.get_watch_playlist(sample_video) + song = self.yt_auth.get_song_related(song["related"]) + self.assertEqual(len(song), 5) + def test_get_lyrics(self): playlist = self.yt.get_watch_playlist(sample_video) lyrics_song = self.yt.get_lyrics(playlist["lyrics"]) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index c5c0ce08..26b07b19 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -73,8 +73,8 @@ def get_home(self, limit=3) -> List[Dict]: }], "thumbnails": [...], "album": { - "title": "Gravity", - "browseId": "MPREb_D6bICFcuuRY" + "name": "Gravity", + "id": "MPREb_D6bICFcuuRY" } }, { //video quick pick @@ -99,14 +99,14 @@ def get_home(self, limit=3) -> List[Dict]: response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) home = [] - home.extend(self.parser.parse_home(results)) + home.extend(self.parser.parse_mixed_content(results)) section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) if 'continuations' in section_list: request_func = lambda additionalParams: self._send_request( endpoint, body, additionalParams) - parse_func = lambda contents: self.parser.parse_home(contents) + parse_func = lambda contents: self.parser.parse_mixed_content(contents) home.extend( get_continuations(section_list, 'sectionListContinuation', limit - len(home), @@ -212,7 +212,7 @@ def get_artist(self, channelId: str) -> Dict: header = response['header']['musicImmersiveHeaderRenderer'] artist['name'] = nav(header, TITLE_TEXT) descriptionShelf = find_object_by_key(results, - 'musicDescriptionShelfRenderer', + DESCRIPTION_SHELF[0], is_key=True) if descriptionShelf: artist['description'] = nav(descriptionShelf, DESCRIPTION) @@ -573,6 +573,87 @@ def get_song(self, videoId: str, signatureTimestamp: int = None) -> Dict: del response[k] return response + def get_song_related(self, browseId: str): + """ + Gets related content for a song. Equivalent to the content + shown in the "Related" tab of the watch panel. + + :param browseId: The `related` key in the `get_watch_playlist` response. + + Example:: + + [ + { + "title": "You might also like", + "contents": [ + { + "title": "High And Dry", + "videoId": "7fv84nPfTH0", + "artists": [{ + "name": "Radiohead", + "id": "UCr_iyUANcn9OX_yy9piYoLw" + }], + "thumbnails": [ + { + "url": "https://lh3.googleusercontent.com/TWWT47cHLv3yAugk4h9eOzQ46FHmXc_g-KmBVy2d4sbg_F-Gv6xrPglztRVzp8D_l-yzOnvh-QToM8s=w60-h60-l90-rj", + "width": 60, + "height": 60 + } + ], + "isExplicit": false, + "album": { + "name": "The Bends", + "id": "MPREb_xsmDKhqhQrG" + } + } + ] + }, + { + "title": "Recommended playlists", + "contents": [ + { + "title": "'90s Alternative Rock Hits", + "playlistId": "RDCLAK5uy_m_h-nx7OCFaq9AlyXv78lG0AuloqW_NUA", + "thumbnails": [...], + "description": "Playlist • YouTube Music" + } + ] + }, + { + "title": "Similar artists", + "contents": [ + { + "title": "Noel Gallagher", + "browseId": "UCu7yYcX_wIZgG9azR3PqrxA", + "subscribers": "302K", + "thumbnails": [...] + } + ] + }, + { + "title": "Oasis", + "contents": [ + { + "title": "Shakermaker", + "year": "2014", + "browseId": "MPREb_WNGQWp5czjD", + "thumbnails": [...] + } + ] + }, + { + "title": "About the artist", + "contents": "Oasis were a rock band consisting of Liam Gallagher, Paul ... (full description shortened for documentation)" + } + ] + """ + if not browseId: + raise Exception("Invalid browseId provided.") + + response = self._send_request('browse', {'browseId': browseId}) + sections = nav(response, ['contents'] + SECTION_LIST) + return self.parser.parse_mixed_content(sections) + def get_lyrics(self, browseId: str) -> Dict: """ Returns lyrics of a song or video. @@ -594,9 +675,9 @@ def get_lyrics(self, browseId: str) -> Dict: response = self._send_request('browse', {'browseId': browseId}) lyrics['lyrics'] = nav(response, ['contents'] + SECTION_LIST_ITEM - + ['musicDescriptionShelfRenderer'] + DESCRIPTION, True) + + DESCRIPTION_SHELF + DESCRIPTION, True) lyrics['source'] = nav(response, ['contents'] + SECTION_LIST_ITEM - + ['musicDescriptionShelfRenderer', 'footer'] + RUN_TEXT, True) + + DESCRIPTION_SHELF + ['footer'] + RUN_TEXT, True) return lyrics diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 4323ac11..19370757 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -29,8 +29,10 @@ 'musicVideoType' ] HEADER_DETAIL = ['header', 'musicDetailHeaderRenderer'] +DESCRIPTION_SHELF = ['musicDescriptionShelfRenderer'] DESCRIPTION = ['description'] + RUN_TEXT CAROUSEL = ['musicCarouselShelfRenderer'] +IMMERSIVE_CAROUSEL = ['musicImmersiveCarouselShelfRenderer'] CAROUSEL_CONTENTS = CAROUSEL + ['contents'] CAROUSEL_TITLE = ['header', 'musicCarouselShelfBasicHeaderRenderer', 'title', 'runs', 0] FRAMEWORK_MUTATIONS = ['frameworkUpdates', 'entityBatchUpdate', 'mutations'] diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 6cb4054b..ed325dc0 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -7,49 +7,39 @@ class Parser: def __init__(self, language): self.lang = language - def parse_home(self, rows): + def parse_mixed_content(self, rows): items = [] for row in rows: - contents = [] - if CAROUSEL[0] in row: - results = nav(row, CAROUSEL) - elif 'musicImmersiveCarouselShelfRenderer' in row: - results = row['musicImmersiveCarouselShelfRenderer'] + if DESCRIPTION_SHELF[0] in row: + results = nav(row, DESCRIPTION_SHELF) + title = nav(results, ['header'] + RUN_TEXT) + contents = nav(results, DESCRIPTION) else: - continue - for result in results['contents']: - data = nav(result, [MTRIR], True) - content = None - if data: - page_type = nav(data, TITLE + NAVIGATION_BROWSE + PAGE_TYPE, True) - if page_type is None: # song - content = parse_song(data) - elif page_type == "MUSIC_PAGE_TYPE_ALBUM": - content = parse_album(data) - elif page_type == "MUSIC_PAGE_TYPE_ARTIST": - content = parse_related_artist(data) - elif page_type == "MUSIC_PAGE_TYPE_PLAYLIST": - content = parse_playlist(data) - else: - data = nav(result, [MRLIR]) - columns = [ - get_flex_column_item(data, i) for i in range(0, len(data['flexColumns'])) - ] - content = { - 'title': nav(columns[0], TEXT_RUN_TEXT), - 'videoId': nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID), - 'thumbnails': nav(data, THUMBNAILS) - } - content.update(parse_song_runs(nav(columns[1], TEXT_RUNS))) - if len(columns) > 2 and columns[2] is not None: - content['album'] = { - 'title': nav(columns[2], TEXT_RUN_TEXT), - 'browseId': nav(columns[2], TEXT_RUN + NAVIGATION_BROWSE_ID) - } - - contents.append(content) - - items.append({'title': nav(results, CAROUSEL_TITLE + ['text']), 'contents': contents}) + results = next(iter(row.values())) + if 'contents' not in results: + continue + title = nav(results, CAROUSEL_TITLE + ['text']) + contents = [] + for result in results['contents']: + data = nav(result, [MTRIR], True) + content = None + if data: + page_type = nav(data, TITLE + NAVIGATION_BROWSE + PAGE_TYPE, True) + if page_type is None: # song + content = parse_song(data) + elif page_type == "MUSIC_PAGE_TYPE_ALBUM": + content = parse_album(data) + elif page_type == "MUSIC_PAGE_TYPE_ARTIST": + content = parse_related_artist(data) + elif page_type == "MUSIC_PAGE_TYPE_PLAYLIST": + content = parse_playlist(data) + else: + data = nav(result, [MRLIR]) + content = parse_song_flat(data) + + contents.append(content) + + items.append({'title': title, 'contents': contents}) return items @i18n @@ -236,6 +226,28 @@ def parse_song(result): return song +def parse_song_flat(data): + columns = [ + get_flex_column_item(data, i) for i in range(0, len(data['flexColumns'])) + ] + song = { + 'title': nav(columns[0], TEXT_RUN_TEXT), + 'videoId': nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID, True), + 'artists': parse_song_artists(data, 1), + 'thumbnails': nav(data, THUMBNAILS), + 'isExplicit': nav(data, BADGE_LABEL, True) is not None + } + if len(columns) > 2 and columns[2] is not None and 'navigationEndpoint' in nav(columns[2], TEXT_RUN): + song['album'] = { + 'name': nav(columns[2], TEXT_RUN_TEXT), + 'id': nav(columns[2], TEXT_RUN + NAVIGATION_BROWSE_ID) + } + else: + song['views'] = nav(columns[1], ['text', 'runs', -1, 'text']).split(' ')[0] + + return song + + def parse_video(result): runs = result['subtitle']['runs'] artists_len = get_dot_separator_index(runs) diff --git a/ytmusicapi/parsers/explore.py b/ytmusicapi/parsers/explore.py index ecb13d10..6f1a371d 100644 --- a/ytmusicapi/parsers/explore.py +++ b/ytmusicapi/parsers/explore.py @@ -4,23 +4,7 @@ def parse_chart_song(data): - flex_0 = get_flex_column_item(data, 0) - parsed = { - 'title': nav(flex_0, TEXT_RUN_TEXT), - 'videoId': nav(flex_0, TEXT_RUN + NAVIGATION_VIDEO_ID, True), - 'artists': parse_song_artists(data, 1), - 'thumbnails': nav(data, THUMBNAILS), - 'isExplicit': nav(data, BADGE_LABEL, True) is not None - } - flex_2 = get_flex_column_item(data, 2) - if flex_2 and 'navigationEndpoint' in nav(flex_2, TEXT_RUN): - parsed['album'] = { - 'name': nav(flex_2, TEXT_RUN_TEXT), - 'id': nav(flex_2, TEXT_RUN + NAVIGATION_BROWSE_ID) - } - else: - flex_1 = get_flex_column_item(data, 1) - parsed['views'] = nav(flex_1, ['text', 'runs', -1, 'text']).split(' ')[0] + parsed = parse_song_flat(data) parsed.update(parse_ranking(data)) return parsed From 7e5697596ce54ce229b245e685252f92081481d8 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 10 May 2022 14:28:20 +0200 Subject: [PATCH 053/238] Update version to 0.22.0 --- ytmusicapi/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/_version.py b/ytmusicapi/_version.py index 6a726d85..5963297e 100644 --- a/ytmusicapi/_version.py +++ b/ytmusicapi/_version.py @@ -1 +1 @@ -__version__ = "0.21.0" +__version__ = "0.22.0" From 1d5cfc3b3cff1619b3a045ebdecbf01a2f11ef9d Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 2 Aug 2022 17:07:43 +0200 Subject: [PATCH 054/238] update tests sample video to version with lyrics --- tests/test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test.py b/tests/test.py index b6356178..df60f54c 100644 --- a/tests/test.py +++ b/tests/test.py @@ -9,7 +9,7 @@ config.read('./test.cfg', 'utf-8') sample_album = "MPREb_4pL8gzRtw1p" # Eminem - Revival -sample_video = "ZrOKjDZOtkA" # Oasis - Wonderwall (Remastered) +sample_video = "tGWs0xKwhag" # Oasis - Wonderwall (Remastered) sample_playlist = "PL6bPxvf5dW5clc3y9wAoslzqUrmkZ5c-u" # very large playlist @@ -142,7 +142,7 @@ def test_get_song(self): def test_get_song_related_content(self): song = self.yt_auth.get_watch_playlist(sample_video) song = self.yt_auth.get_song_related(song["related"]) - self.assertEqual(len(song), 5) + self.assertGreaterEqual(len(song), 5) def test_get_lyrics(self): playlist = self.yt.get_watch_playlist(sample_video) From b3e233e75c432151d049467c881f928deaffd425 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 2 Aug 2022 17:07:55 +0200 Subject: [PATCH 055/238] add tunerSettingValue to get_watch_playlist request (#282) --- ytmusicapi/mixins/watch.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index 67452ee7..6a5aa856 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -97,7 +97,11 @@ def get_watch_playlist(self, } """ - body = {'enablePersistentPlaylistPanel': True, 'isAudioOnly': True} + body = { + 'enablePersistentPlaylistPanel': True, + 'isAudioOnly': True, + 'tunerSettingValue': 'AUTOMIX_SETTING_NORMAL' + } if not videoId and not playlistId: raise Exception("You must provide either a video id, a playlist id, or both") if videoId: From fbb26dde94e3d13cebea5a0ca05bb869c6e39fd2 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Fri, 5 Aug 2022 14:54:35 +0200 Subject: [PATCH 056/238] search: raise Exception when filter is set for uploads search (closes #285) --- tests/test.py | 6 ++++++ ytmusicapi/mixins/search.py | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/tests/test.py b/tests/test.py index df60f54c..50eae4e8 100644 --- a/tests/test.py +++ b/tests/test.py @@ -69,6 +69,12 @@ def test_search(self): self.assertGreater(len(results), 5) def test_search_uploads(self): + self.assertRaises(Exception, + self.yt.search, + 'audiomachine', + filter="songs", + scope='uploads', + limit=40) results = self.yt_auth.search('audiomachine', scope='uploads', limit=40) self.assertGreater(len(results), 20) diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 9164c1d3..281e61dc 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -19,6 +19,7 @@ def search(self, :param filter: Filter for item types. Allowed values: ``songs``, ``videos``, ``albums``, ``artists``, ``playlists``, ``community_playlists``, ``featured_playlists``, ``uploads``. Default: Default search, including all types of items. :param scope: Search scope. Allowed values: ``library``, ``uploads``. + For uploads, no filter can be set! An exception will be thrown if you attempt to do so. Default: Search the public YouTube Music catalogue. :param limit: Number of search results to return Default: 20 @@ -137,6 +138,12 @@ def search(self, "Invalid scope provided. Please use one of the following scopes or leave out the parameter: " + ', '.join(scopes)) + if scope == scopes[1] and filter: + raise Exception( + "No filter can be set when searching uploads. Please unset the filter parameter when scope is set to " + "uploads. " + ) + params = get_search_params(filter, scope, ignore_spelling) if params: body['params'] = params From d66f20aa13ce2117fce7abcadbb9ae288bd352e7 Mon Sep 17 00:00:00 2001 From: Adrian Date: Sat, 20 Aug 2022 23:43:39 -0700 Subject: [PATCH 057/238] Addded favorite artists to taste --- tests/test.py | 15 +++++++--- ytmusicapi/mixins/browsing.py | 51 +++++++++++++++++++++++++++++----- ytmusicapi/navigation.py | 2 ++ ytmusicapi/parsers/browsing.py | 21 +++++++++++--- 4 files changed, 74 insertions(+), 15 deletions(-) diff --git a/tests/test.py b/tests/test.py index 50eae4e8..0547838b 100644 --- a/tests/test.py +++ b/tests/test.py @@ -164,9 +164,16 @@ def test_get_signatureTimestamp(self): signatureTimestamp = self.yt.get_signatureTimestamp() self.assertIsNotNone(signatureTimestamp) - ############### + def test_set_tasteprofile(self): + self.assertRaises(self.yt.set_tasteprofile("Galactic")) + + def test_get_tasteprofile(self): + result = self.yt.get_tasteprofiles() + self.assertGreaterEqual(len(result), 0) + + ################ # EXPLORE - ############### + ################ def test_get_mood_playlists(self): categories = self.yt.get_mood_categories() @@ -206,9 +213,9 @@ def test_get_watch_playlist_shuffle_playlist(self): playlist = self.yt_brand.get_watch_playlist_shuffle(playlistId=config['playlists']['own']) self.assertEqual(len(playlist['tracks']), 4) - ############### + ################ # LIBRARY - ############### + ################ def test_get_library_playlists(self): playlists = self.yt_auth.get_library_playlists(50) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 26b07b19..67d8edfc 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -211,9 +211,7 @@ def get_artist(self, channelId: str) -> Dict: artist = {'description': None, 'views': None} header = response['header']['musicImmersiveHeaderRenderer'] artist['name'] = nav(header, TITLE_TEXT) - descriptionShelf = find_object_by_key(results, - DESCRIPTION_SHELF[0], - is_key=True) + descriptionShelf = find_object_by_key(results, DESCRIPTION_SHELF[0], is_key=True) if descriptionShelf: artist['description'] = nav(descriptionShelf, DESCRIPTION) artist['views'] = None if 'subheader' not in descriptionShelf else descriptionShelf[ @@ -674,10 +672,11 @@ def get_lyrics(self, browseId: str) -> Dict: raise Exception("Invalid browseId provided. This song might not have lyrics.") response = self._send_request('browse', {'browseId': browseId}) - lyrics['lyrics'] = nav(response, ['contents'] + SECTION_LIST_ITEM - + DESCRIPTION_SHELF + DESCRIPTION, True) - lyrics['source'] = nav(response, ['contents'] + SECTION_LIST_ITEM - + DESCRIPTION_SHELF + ['footer'] + RUN_TEXT, True) + lyrics['lyrics'] = nav(response, + ['contents'] + SECTION_LIST_ITEM + DESCRIPTION_SHELF + DESCRIPTION, + True) + lyrics['source'] = nav(response, ['contents'] + SECTION_LIST_ITEM + DESCRIPTION_SHELF + + ['footer'] + RUN_TEXT, True) return lyrics @@ -711,3 +710,41 @@ def get_signatureTimestamp(self, url: str = None) -> int: raise Exception("Unable to identify the signatureTimestamp.") return int(match.group(1)) + + def get_tasteprofiles(self) -> Dict: + """ + Fetches all artists from taste profile (music.youtube.com/tasteprofile) + + :return: Dictionary with artist and their selection & impression value + + Example:: + { + "Drake": { + "selectionValue": "tastebuilder_selection=/m/05mt_q", + "impressionValue": "tastebuilder_impression=/m/05mt_q" + } + } + """ + response = self._send_request('browse', {'browseId': "FEmusic_tastebuilder"}) + return parse_tasteprofiles(response) + + def set_tasteprofile(self, *artists: str) -> None: + """ + Favorites artists to see more recommendations from the artist + """ + + taste_profiles = self.get_tasteprofiles() + formData = { + "impressionValues": + [taste_profiles[profile]["impressionValue"] for profile in taste_profiles], + "selectedValues": [] + } + + for artist in artists: + if artist not in taste_profiles: + raise Exception("The artist, {}, was not present in taste!".format(artist)) + formData["selectedValues"].append(taste_profiles[artist]["selectionValue"]) + + body = {'browseId': "FEmusic_home", "formData": formData} + + self._send_request('browse', body) diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 19370757..2a74fa2f 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -57,6 +57,8 @@ CATEGORY_PARAMS = ['musicNavigationButtonRenderer', 'clickCommand', 'browseEndpoint', 'params'] MRLIR = 'musicResponsiveListItemRenderer' MTRIR = 'musicTwoRowItemRenderer' +TASTE_PROFILE_ITEMS = ["contents", "tastebuilderRenderer", "contents"] +TASTE_PROFILE_ARTIST = ["title", "runs"] def nav(root, items, none_if_absent=False): diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index ed325dc0..177e4f04 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -227,9 +227,7 @@ def parse_song(result): def parse_song_flat(data): - columns = [ - get_flex_column_item(data, i) for i in range(0, len(data['flexColumns'])) - ] + columns = [get_flex_column_item(data, i) for i in range(0, len(data['flexColumns']))] song = { 'title': nav(columns[0], TEXT_RUN_TEXT), 'videoId': nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID, True), @@ -237,7 +235,8 @@ def parse_song_flat(data): 'thumbnails': nav(data, THUMBNAILS), 'isExplicit': nav(data, BADGE_LABEL, True) is not None } - if len(columns) > 2 and columns[2] is not None and 'navigationEndpoint' in nav(columns[2], TEXT_RUN): + if len(columns) > 2 and columns[2] is not None and 'navigationEndpoint' in nav( + columns[2], TEXT_RUN): song['album'] = { 'name': nav(columns[2], TEXT_RUN_TEXT), 'id': nav(columns[2], TEXT_RUN + NAVIGATION_BROWSE_ID) @@ -288,3 +287,17 @@ def parse_related_artist(data): 'subscribers': subscribers, 'thumbnails': nav(data, THUMBNAIL_RENDERER), } + + +def parse_tasteprofiles(data): + profiles = nav(data, TASTE_PROFILE_ITEMS) + + taste_profiles = {} + for itemList in profiles: + for item in itemList["tastebuilderItemListRenderer"]["contents"]: + artist = nav(item["tastebuilderItemRenderer"], TASTE_PROFILE_ARTIST)[0]["text"] + taste_profiles[artist] = { + "selectionValue": item["tastebuilderItemRenderer"]["selectionFormValue"], + "impressionValue": item["tastebuilderItemRenderer"]["impressionFormValue"] + } + return taste_profiles From f7ef0d2c15f9f3f9ee87e92cf6c22b1282c14751 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 23 Aug 2022 20:33:48 -0700 Subject: [PATCH 058/238] Covered more cases in unittest and documentation --- docs/source/reference.rst | 3 ++- tests/test.py | 13 +++++++++-- ytmusicapi/mixins/browsing.py | 42 ++++++++++++++++++++++++---------- ytmusicapi/parsers/browsing.py | 14 ------------ 4 files changed, 43 insertions(+), 29 deletions(-) diff --git a/docs/source/reference.rst b/docs/source/reference.rst index e373f5b8..db3d7699 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -31,7 +31,8 @@ Browsing .. automethod:: YTMusic.get_song .. automethod:: YTMusic.get_song_related .. automethod:: YTMusic.get_lyrics - +.. automethod:: YTMusic.get_tasteprofile +.. automethod:: YTMusic.set_tasteprofile Explore -------- diff --git a/tests/test.py b/tests/test.py index 0547838b..76dabd31 100644 --- a/tests/test.py +++ b/tests/test.py @@ -165,10 +165,19 @@ def test_get_signatureTimestamp(self): self.assertIsNotNone(signatureTimestamp) def test_set_tasteprofile(self): - self.assertRaises(self.yt.set_tasteprofile("Galactic")) + artists = [artist for artist in self.yt.get_tasteprofile()] + + self.assertRaises(self.yt.set_tasteprofile(artists[:1])) + self.assertRaises(self.yt.set_tasteprofile(artists[:5])) + + self.assertRaises(self.yt_auth.set_tasteprofile(artists[:1])) + self.assertRaises(self.yt_auth.set_tasteprofile(artists[:5])) def test_get_tasteprofile(self): - result = self.yt.get_tasteprofiles() + result = self.yt.get_tasteprofile() + self.assertGreaterEqual(len(result), 0) + + result = self.yt_auth.get_tasteprofile() self.assertGreaterEqual(len(result), 0) ################ diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 67d8edfc..ae412704 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -711,29 +711,48 @@ def get_signatureTimestamp(self, url: str = None) -> int: return int(match.group(1)) - def get_tasteprofiles(self) -> Dict: + def get_tasteprofile(self) -> Dict: """ - Fetches all artists from taste profile (music.youtube.com/tasteprofile) + Fetches suggested artists from taste profile (music.youtube.com/tasteprofile). + Tasteprofile allows users to pick artists to update their reccomendations. + Only returns a list of suggested artists, not the actual list of selected entries :return: Dictionary with artist and their selection & impression value Example:: - { - "Drake": { - "selectionValue": "tastebuilder_selection=/m/05mt_q", - "impressionValue": "tastebuilder_impression=/m/05mt_q" + + { + "Drake": { + "selectionValue": "tastebuilder_selection=/m/05mt_q" + "impressionValue": "tastebuilder_impression=/m/05mt_q" + } } - } + """ + response = self._send_request('browse', {'browseId': "FEmusic_tastebuilder"}) - return parse_tasteprofiles(response) + profiles = nav(response, TASTE_PROFILE_ITEMS) + + taste_profiles = {} + for itemList in profiles: + for item in itemList["tastebuilderItemListRenderer"]["contents"]: + artist = nav(item["tastebuilderItemRenderer"], TASTE_PROFILE_ARTIST)[0]["text"] + taste_profiles[artist] = { + "selectionValue": item["tastebuilderItemRenderer"]["selectionFormValue"], + "impressionValue": item["tastebuilderItemRenderer"]["impressionFormValue"] + } + return taste_profiles - def set_tasteprofile(self, *artists: str) -> None: + def set_tasteprofile(self, artists: List[str]) -> None: """ - Favorites artists to see more recommendations from the artist + Favorites artists to see more recommendations from the artist. + Use get_tasteprofile() to see which artists are available to be recommended + + :param artists: A List with names of artists + """ - taste_profiles = self.get_tasteprofiles() + taste_profiles = self.get_tasteprofile() formData = { "impressionValues": [taste_profiles[profile]["impressionValue"] for profile in taste_profiles], @@ -746,5 +765,4 @@ def set_tasteprofile(self, *artists: str) -> None: formData["selectedValues"].append(taste_profiles[artist]["selectionValue"]) body = {'browseId': "FEmusic_home", "formData": formData} - self._send_request('browse', body) diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 177e4f04..cf8d63fe 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -287,17 +287,3 @@ def parse_related_artist(data): 'subscribers': subscribers, 'thumbnails': nav(data, THUMBNAIL_RENDERER), } - - -def parse_tasteprofiles(data): - profiles = nav(data, TASTE_PROFILE_ITEMS) - - taste_profiles = {} - for itemList in profiles: - for item in itemList["tastebuilderItemListRenderer"]["contents"]: - artist = nav(item["tastebuilderItemRenderer"], TASTE_PROFILE_ARTIST)[0]["text"] - taste_profiles[artist] = { - "selectionValue": item["tastebuilderItemRenderer"]["selectionFormValue"], - "impressionValue": item["tastebuilderItemRenderer"]["impressionFormValue"] - } - return taste_profiles From c3ed85002660ea313ed97de2b97c7e64f05ba385 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 24 Aug 2022 09:00:08 +0200 Subject: [PATCH 059/238] tasteprofile: minor fixes to test (#289) --- tests/test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test.py b/tests/test.py index 76dabd31..04486213 100644 --- a/tests/test.py +++ b/tests/test.py @@ -167,11 +167,11 @@ def test_get_signatureTimestamp(self): def test_set_tasteprofile(self): artists = [artist for artist in self.yt.get_tasteprofile()] - self.assertRaises(self.yt.set_tasteprofile(artists[:1])) - self.assertRaises(self.yt.set_tasteprofile(artists[:5])) + self.assertRaises(Exception, self.yt.set_tasteprofile, "not an artist") + self.assertIsNone(self.yt.set_tasteprofile(artists[:5])) - self.assertRaises(self.yt_auth.set_tasteprofile(artists[:1])) - self.assertRaises(self.yt_auth.set_tasteprofile(artists[:5])) + self.assertIsNone(self.yt_brand.set_tasteprofile(artists[:1])) + self.assertIsNone(self.yt_brand.set_tasteprofile(artists[:5])) def test_get_tasteprofile(self): result = self.yt.get_tasteprofile() From 4512dac4091a2c639077c5f13f18415a9a3c9a18 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 24 Aug 2022 09:45:20 +0200 Subject: [PATCH 060/238] set_tasteprofile: make internal get_tasteprofile request optional (#289) --- tests/test.py | 10 +++++----- ytmusicapi/mixins/browsing.py | 23 +++++++++++++---------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/tests/test.py b/tests/test.py index 04486213..0896a249 100644 --- a/tests/test.py +++ b/tests/test.py @@ -165,13 +165,13 @@ def test_get_signatureTimestamp(self): self.assertIsNotNone(signatureTimestamp) def test_set_tasteprofile(self): - artists = [artist for artist in self.yt.get_tasteprofile()] - self.assertRaises(Exception, self.yt.set_tasteprofile, "not an artist") - self.assertIsNone(self.yt.set_tasteprofile(artists[:5])) + taste_profile = self.yt.get_tasteprofile() + self.assertIsNone(self.yt.set_tasteprofile(list(taste_profile)[:5], taste_profile)) - self.assertIsNone(self.yt_brand.set_tasteprofile(artists[:1])) - self.assertIsNone(self.yt_brand.set_tasteprofile(artists[:5])) + self.assertRaises(Exception, self.yt_brand.set_tasteprofile, ["test", "test2"]) + taste_profile = self.yt_brand.get_tasteprofile() + self.assertIsNone(self.yt_brand.set_tasteprofile(list(taste_profile)[:1], taste_profile)) def test_get_tasteprofile(self): result = self.yt.get_tasteprofile() diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index ae412704..113b8c77 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -714,7 +714,7 @@ def get_signatureTimestamp(self, url: str = None) -> int: def get_tasteprofile(self) -> Dict: """ Fetches suggested artists from taste profile (music.youtube.com/tasteprofile). - Tasteprofile allows users to pick artists to update their reccomendations. + Tasteprofile allows users to pick artists to update their recommendations. Only returns a list of suggested artists, not the actual list of selected entries :return: Dictionary with artist and their selection & impression value @@ -725,11 +725,11 @@ def get_tasteprofile(self) -> Dict: "Drake": { "selectionValue": "tastebuilder_selection=/m/05mt_q" "impressionValue": "tastebuilder_impression=/m/05mt_q" - } + } } """ - + response = self._send_request('browse', {'browseId': "FEmusic_tastebuilder"}) profiles = nav(response, TASTE_PROFILE_ITEMS) @@ -743,26 +743,29 @@ def get_tasteprofile(self) -> Dict: } return taste_profiles - def set_tasteprofile(self, artists: List[str]) -> None: + def set_tasteprofile(self, artists: List[str], taste_profile: Dict = None) -> None: """ Favorites artists to see more recommendations from the artist. Use get_tasteprofile() to see which artists are available to be recommended - :param artists: A List with names of artists - + :param artists: A List with names of artists, must be contained in the tasteprofile + :param taste_profile: tasteprofile result from :py:func:`get_tasteprofile`. + Pass this if you call :py:func:`get_tasteprofile` anyway to save an extra request. + :return None if successful """ - taste_profiles = self.get_tasteprofile() + if taste_profile is None: + taste_profile = self.get_tasteprofile() formData = { "impressionValues": - [taste_profiles[profile]["impressionValue"] for profile in taste_profiles], + [taste_profile[profile]["impressionValue"] for profile in taste_profile], "selectedValues": [] } for artist in artists: - if artist not in taste_profiles: + if artist not in taste_profile: raise Exception("The artist, {}, was not present in taste!".format(artist)) - formData["selectedValues"].append(taste_profiles[artist]["selectionValue"]) + formData["selectedValues"].append(taste_profile[artist]["selectionValue"]) body = {'browseId': "FEmusic_home", "formData": formData} self._send_request('browse', body) From b3c50c28c7547945319ac9e183d6087a727e7498 Mon Sep 17 00:00:00 2001 From: s1ack Date: Fri, 2 Sep 2022 08:54:09 +0530 Subject: [PATCH 061/238] Update setup.rst Added a bit more clarity --- docs/source/setup.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/setup.rst b/docs/source/setup.rst index 44a4a331..b5c9aa9a 100644 --- a/docs/source/setup.rst +++ b/docs/source/setup.rst @@ -13,7 +13,7 @@ Authenticated requests Copy authentication headers ^^^^^^^^^^^^^^^^^^^^^^^^^^^ -To run authenticated requests you need to set up you need to copy your request headers from a POST request in your browser. +To run authenticated requests, set it up by firstly copying your request headers from an authenticated POST request in your browser. To do so, follow these steps: - Open a new tab From 20b4631c31b65d5d40caf89b5db5fd661e82a979 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Fri, 2 Sep 2022 11:20:12 +0200 Subject: [PATCH 062/238] setup docs: grammar fix --- docs/source/setup.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/setup.rst b/docs/source/setup.rst index b5c9aa9a..ae0ee7aa 100644 --- a/docs/source/setup.rst +++ b/docs/source/setup.rst @@ -13,7 +13,7 @@ Authenticated requests Copy authentication headers ^^^^^^^^^^^^^^^^^^^^^^^^^^^ -To run authenticated requests, set it up by firstly copying your request headers from an authenticated POST request in your browser. +To run authenticated requests, set it up by first copying your request headers from an authenticated POST request in your browser. To do so, follow these steps: - Open a new tab From 07cb6d041f0f6bfc7e4611cb9fb57eb2c40696f3 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 20 Sep 2022 17:45:13 +0200 Subject: [PATCH 063/238] Change version string to new format (#296) --- ytmusicapi/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index 03d43ef6..e174e709 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -23,7 +23,7 @@ def initialize_context(): 'context': { 'client': { 'clientName': 'WEB_REMIX', - 'clientVersion': '0.1' + 'clientVersion': '1.' + time.strftime("%Y%m%d", time.gmtime()) + '.01.00' }, 'user': {} } From 889b29b530f15fbf322634889db137c79b894d6a Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 22 Sep 2022 18:07:26 +0200 Subject: [PATCH 064/238] fix language exception error message --- ytmusicapi/ytmusic.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index fef81993..80b8c935 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -102,10 +102,10 @@ def __init__(self, self.context = initialize_context() self.context['context']['client']['hl'] = language locale_dir = os.path.abspath(os.path.dirname(__file__)) + os.sep + 'locales' - supported_languages = [f for f in os.listdir(locale_dir)] + supported_languages = [f for f in next(os.walk(locale_dir))[1]] if language not in supported_languages: - raise Exception("Language not supported. Supported languages are " - ', '.join(supported_languages)) + raise Exception("Language not supported. Supported languages are " + + (', '.join(supported_languages)) + ".") self.language = language try: locale.setlocale(locale.LC_ALL, self.language) From b7a3e7544d7ac5e3ec9351a04bb6c0d013284f02 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 22 Sep 2022 18:26:12 +0200 Subject: [PATCH 065/238] use pyproject.toml for build --- .github/workflows/pythonpublish.yml | 39 ++++++++++++++++------------- .gitignore | 4 ++- MANIFEST.in | 3 --- README.rst | 2 +- pyproject.toml | 37 +++++++++++++++++++++++++++ setup.py | 29 --------------------- 6 files changed, 63 insertions(+), 51 deletions(-) delete mode 100644 MANIFEST.in create mode 100644 pyproject.toml delete mode 100644 setup.py diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index c05a1de9..36855e19 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -2,25 +2,30 @@ name: Upload Python Package on: release: - types: [created] + types: [published] + +permissions: + contents: read jobs: deploy: + runs-on: ubuntu-latest + steps: - - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v1 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install setuptools wheel twine - - name: Build - run: | - python setup.py sdist bdist_wheel - - name: Publish distribution 📦 to PyPI - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{ secrets.PYPI_PASSWORD }} + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: '3.x' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + - name: Build package + run: python -m build + - name: Publish package + uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + with: + user: __token__ + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index d2c22fc0..d8ecf8c8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ docs/build .coverage **/__pycache__/ venv*/ -*.egg-info/ \ No newline at end of file +*.egg-info/ +build +dist diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 438fe27c..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include README.md -recursive-include ytmusicapi/locales *.mo -recursive-include ytmusicapi *.py \ No newline at end of file diff --git a/README.rst b/README.rst index 3d9cec0a..4c0fc0c8 100644 --- a/README.rst +++ b/README.rst @@ -79,7 +79,7 @@ The `tests `_ a Requirements ============== -- Python 3.6 or higher - https://www.python.org +- Python 3.7 or higher - https://www.python.org Setup and Usage =============== diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..0bcc0584 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "ytmusicapi" +description = "Unofficial API for YouTube Music" +requires-python = ">=3.7" +authors=[{name = "sigma67", email= "ytmusicapi@gmail.com"}] +license={file="LICENSE"} +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", +] +dependencies = [ + "requests >= 2.22", +] +dynamic = ["version", "readme"] + +[project.optional-dependencies] +dev = ['pre-commit', 'flake8', 'yapf', 'coverage', 'sphinx', 'sphinx-rtd-theme'] + +[project.urls] +homepage = "https://github.com/sigma67/ytmusicapi" +documentation = "https://ytmusicapi.readthedocs.io" +repository = "https://github.com/sigma67/ytmusicapi" + +[build-system] +requires = ["setuptools>=65", "setuptools_scm[toml]>=7"] +build-backend = "setuptools.build_meta" + +[tool.setuptools_scm] + +[tool.setuptools.dynamic] +readme = {file = ["README.rst"]} + +[tool.setuptools] +include-package-data=false + +[tool.setuptools.package-data] +"*" = ["**.rst", "**.py", "**.mo"] diff --git a/setup.py b/setup.py deleted file mode 100644 index 6ab222b2..00000000 --- a/setup.py +++ /dev/null @@ -1,29 +0,0 @@ -from setuptools import setup, find_packages -import re - -VERSIONFILE = 'ytmusicapi/_version.py' - -version_line = open(VERSIONFILE).read() -version_re = r"^__version__ = ['\"]([^'\"]*)['\"]" -match = re.search(version_re, version_line, re.M) -if match: - version = match.group(1) -else: - raise RuntimeError("Could not find version in '%s'" % VERSIONFILE) - -setup(name='ytmusicapi', - version=version, - description='Unofficial API for YouTube Music', - long_description=(open('README.rst').read()), - url='https://github.com/sigma67/ytmusicapi', - author='sigma67', - author_email='', - license='MIT', - packages=find_packages(), - install_requires=['requests >= 2.22'], - extras_require={ - 'dev': ['pre-commit', 'flake8', 'yapf', 'coverage', 'sphinx', 'sphinx-rtd-theme'] - }, - python_requires=">=3.6", - include_package_data=True, - zip_safe=False) From 1b94985014dc8eb5df4a2de3cdca75c883e0615e Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 26 Sep 2022 10:36:02 +0200 Subject: [PATCH 066/238] retrieve all continuations when limit parameter is None (closes #300) Squashed commit of the following: commit 3f447e39a403bea746b18e79cd7a9177a20ef79c Merge: 8746a2a c31c8da Author: Adrian Date: Sun Sep 25 12:17:44 2022 -0400 Merge branch 'master' of https://github.com/adrinu/ytmusicapi into retrieve-all-playlists commit 8746a2af5bdc12e5e16a87b6c69d39dde8712390 Author: Adrian Date: Sun Sep 25 12:17:06 2022 -0400 Limit to other funcs commit 5b69523003b6d0c2a00e31e1ed4a022efeabee65 Merge: b7a3e75 d36d85d Author: Adrian Date: Sun Sep 25 12:07:45 2022 -0400 Merge branch 'retrieve-all-playlists' of https://github.com/Auzzy/ytmusicapi into retrieve-all-playlists commit c31c8dafae0c805dee68d2df19b3fe1ac454d707 Author: Adrian Date: Sun Sep 25 11:35:05 2022 -0400 Limit is none implementation commit 6768f1d66de832c17b5557c910f4b2a353386690 Merge: b7a3e75 d36d85d Author: Adrian Date: Sun Sep 25 01:06:59 2022 -0400 Merge branch 'retrieve-all-playlists' of https://github.com/Auzzy/ytmusicapi into retrieve-all-playlists commit d36d85de9b3dff08a9997ca712817724b01608a3 Author: Austin Noto-Moniz Date: Mon May 30 16:55:53 2022 -0400 get_library_playlists: When limit is None, return them all. --- tests/test.py | 51 ++++++++++++++++++++++++++++------- ytmusicapi/continuations.py | 2 +- ytmusicapi/mixins/library.py | 7 ++--- ytmusicapi/mixins/uploads.py | 14 +++++----- ytmusicapi/parsers/library.py | 8 +++--- 5 files changed, 60 insertions(+), 22 deletions(-) diff --git a/tests/test.py b/tests/test.py index 0896a249..418bc8ff 100644 --- a/tests/test.py +++ b/tests/test.py @@ -230,6 +230,17 @@ def test_get_library_playlists(self): playlists = self.yt_auth.get_library_playlists(50) self.assertGreater(len(playlists), 25) + def test_get_all_library_playlists(self): + current_length = len(self.yt_auth.get_library_playlists(25)) + expected_length = current_length + while expected_length <= current_length: + expected_length = current_length + 1 + current_length = len(self.yt_auth.get_library_playlists(expected_length)) + expected_length -= 1 + + playlists = self.yt_auth.get_library_playlists(None) + self.assertEqual(len(playlists), expected_length) + def test_get_library_songs(self): songs = self.yt_brand.get_library_songs(100) self.assertGreaterEqual(len(songs), 100) @@ -374,8 +385,15 @@ def test_end2end(self): ############### def test_get_library_upload_songs(self): - results = self.yt_auth.get_library_upload_songs(50, order='z_to_a') - self.assertGreater(len(results), 25) + current_length = len(self.yt_auth.get_library_upload_songs(25)) + expected_length = current_length + while expected_length <= current_length: + expected_length = current_length + 1 + current_length = len(self.yt_auth.get_library_upload_songs(expected_length)) + expected_length -= 1 + + songs = self.yt_auth.get_library_upload_songs(None) + self.assertEqual(len(songs), expected_length) @unittest.skip("Must not have any uploaded songs to pass") def test_get_library_upload_songs_empty(self): @@ -383,8 +401,15 @@ def test_get_library_upload_songs_empty(self): self.assertEquals(len(results), 0) def test_get_library_upload_albums(self): - results = self.yt_auth.get_library_upload_albums(50, order='a_to_z') - self.assertGreater(len(results), 40) + current_length = len(self.yt_auth.get_library_upload_album(25)) + expected_length = current_length + while expected_length <= current_length: + expected_length = current_length + 1 + current_length = len(self.yt_auth.get_library_upload_album(expected_length)) + expected_length -= 1 + + albums = self.yt_auth.get_library_upload_album(None) + self.assertEqual(len(albums), expected_length) @unittest.skip("Must not have any uploaded albums to pass") def test_get_library_upload_albums_empty(self): @@ -392,14 +417,22 @@ def test_get_library_upload_albums_empty(self): self.assertEquals(len(results), 0) def test_get_library_upload_artists(self): - results = self.yt_auth.get_library_upload_artists(50) - self.assertGreater(len(results), 25) + current_length = len(self.yt_auth.get_library_upload_artists(25)) + expected_length = current_length + while expected_length <= current_length: + expected_length = current_length + 1 + current_length = len(self.yt_auth.get_library_upload_artists(expected_length)) + expected_length -= 1 + + artists = self.yt_auth.get_library_upload_artists(None) + self.assertEqual(len(artists), expected_length) + results = self.yt_auth.get_library_upload_artists(50, order='a_to_z') - self.assertGreater(len(results), 25) + self.assertGreaterEqual(len(results), current_length) results = self.yt_auth.get_library_upload_artists(50, order='z_to_a') - self.assertGreater(len(results), 25) + self.assertGreaterEqual(len(results), current_length) results = self.yt_auth.get_library_upload_artists(50, order='recently_added') - self.assertGreater(len(results), 25) + self.assertGreaterEqual(len(results), current_length) @unittest.skip("Must not have any uploaded artsts to pass") def test_get_library_upload_artists_empty(self): diff --git a/ytmusicapi/continuations.py b/ytmusicapi/continuations.py index aedd8d53..97a48851 100644 --- a/ytmusicapi/continuations.py +++ b/ytmusicapi/continuations.py @@ -3,7 +3,7 @@ def get_continuations(results, continuation_type, limit, request_func, parse_func, ctoken_path=""): items = [] - while 'continuations' in results and len(items) < limit: + while 'continuations' in results and (limit is None or len(items) < limit): additionalParams = get_continuation_params(results, ctoken_path) response = request_func(additionalParams) if 'continuationContents' in response: diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index 212601df..f2081eed 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -9,7 +9,7 @@ def get_library_playlists(self, limit: int = 25) -> List[Dict]: """ Retrieves the playlists in the user's library. - :param limit: Number of playlists to retrieve + :param limit: Number of playlists to retrieve. `None` retrieves them all. :return: List of owned playlists. Each item is in the following format:: @@ -35,9 +35,10 @@ def get_library_playlists(self, limit: int = 25) -> List[Dict]: request_func = lambda additionalParams: self._send_request( endpoint, body, additionalParams) parse_func = lambda contents: parse_content_list(contents, parse_playlist) + remaining_limit = None if limit is None else (limit - len(playlists)) playlists.extend( - get_continuations(results, 'gridContinuation', limit - len(playlists), - request_func, parse_func)) + get_continuations(results, 'gridContinuation', remaining_limit, request_func, + parse_func)) return playlists diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index 7eb3e12d..d4aee56f 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -17,7 +17,7 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D """ Returns a list of uploaded songs - :param limit: How many songs to return. Default: 25 + :param limit: How many songs to return. `None` retrieves them all. Default: 25 :param order: Order of songs to return. Allowed values: 'a_to_z', 'z_to_a', 'recently_added'. Default: Default order. :return: List of uploaded songs. @@ -58,9 +58,10 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D if 'continuations' in results: request_func = lambda additionalParams: self._send_request( endpoint, body, additionalParams) + remaining_limit = None if limit is None else (limit - len(songs)) songs.extend( - get_continuations(results, 'musicShelfContinuation', limit - len(songs), - request_func, parse_uploaded_items)) + get_continuations(results, 'musicShelfContinuation', remaining_limit, request_func, + parse_uploaded_items)) return songs @@ -68,7 +69,7 @@ def get_library_upload_albums(self, limit: int = 25, order: str = None) -> List[ """ Gets the albums of uploaded songs in the user's library. - :param limit: Number of albums to return. Default: 25 + :param limit: Number of albums to return. `None` retrives them all. Default: 25 :param order: Order of albums to return. Allowed values: 'a_to_z', 'z_to_a', 'recently_added'. Default: Default order. :return: List of albums as returned by :py:func:`get_library_albums` """ @@ -87,7 +88,7 @@ def get_library_upload_artists(self, limit: int = 25, order: str = None) -> List """ Gets the artists of uploaded songs in the user's library. - :param limit: Number of artists to return. Default: 25 + :param limit: Number of artists to return. `None` retrieves them all. Default: 25 :param order: Order of artists to return. Allowed values: 'a_to_z', 'z_to_a', 'recently_added'. Default: Default order. :return: List of artists as returned by :py:func:`get_library_artists` """ @@ -143,8 +144,9 @@ def get_library_upload_artist(self, browseId: str, limit: int = 25) -> List[Dict request_func = lambda additionalParams: self._send_request( endpoint, body, additionalParams) parse_func = lambda contents: parse_uploaded_items(contents) + remaining_limit = None if limit is None else (limit - len(items)) items.extend( - get_continuations(results, 'musicShelfContinuation', limit, request_func, + get_continuations(results, 'musicShelfContinuation', remaining_limit, request_func, parse_func)) return items diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index e1e51623..5bdd884e 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -34,8 +34,9 @@ def parse_library_albums(response, request_func, limit): if 'continuations' in results: parse_func = lambda contents: parse_albums(contents) + remaining_limit = None if limit is None else (limit - len(albums)) albums.extend( - get_continuations(results, 'gridContinuation', limit - len(albums), request_func, + get_continuations(results, 'gridContinuation', remaining_limit, request_func, parse_func)) return albums @@ -92,9 +93,10 @@ def parse_library_artists(response, request_func, limit): if 'continuations' in results: parse_func = lambda contents: parse_artists(contents) + remaining_limit = None if limit is None else (limit - len(artists)) artists.extend( - get_continuations(results, 'musicShelfContinuation', limit - len(artists), - request_func, parse_func)) + get_continuations(results, 'musicShelfContinuation', remaining_limit, request_func, + parse_func)) return artists From c85fd8c81059b5107ca15ec455628adbd1e39e05 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 26 Sep 2022 11:19:22 +0200 Subject: [PATCH 067/238] optimize tests for retrieving all library items (#275) --- tests/test.py | 39 ++++++++++++--------------------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/tests/test.py b/tests/test.py index 418bc8ff..e3b44a51 100644 --- a/tests/test.py +++ b/tests/test.py @@ -385,15 +385,11 @@ def test_end2end(self): ############### def test_get_library_upload_songs(self): - current_length = len(self.yt_auth.get_library_upload_songs(25)) - expected_length = current_length - while expected_length <= current_length: - expected_length = current_length + 1 - current_length = len(self.yt_auth.get_library_upload_songs(expected_length)) - expected_length -= 1 + results = self.yt_auth.get_library_upload_songs(50, order='z_to_a') + self.assertGreater(len(results), 25) - songs = self.yt_auth.get_library_upload_songs(None) - self.assertEqual(len(songs), expected_length) + # songs = self.yt_auth.get_library_upload_songs(None) + # self.assertEqual(len(songs), 1000) @unittest.skip("Must not have any uploaded songs to pass") def test_get_library_upload_songs_empty(self): @@ -401,15 +397,11 @@ def test_get_library_upload_songs_empty(self): self.assertEquals(len(results), 0) def test_get_library_upload_albums(self): - current_length = len(self.yt_auth.get_library_upload_album(25)) - expected_length = current_length - while expected_length <= current_length: - expected_length = current_length + 1 - current_length = len(self.yt_auth.get_library_upload_album(expected_length)) - expected_length -= 1 + results = self.yt_auth.get_library_upload_albums(50, order='a_to_z') + self.assertGreater(len(results), 40) - albums = self.yt_auth.get_library_upload_album(None) - self.assertEqual(len(albums), expected_length) + albums = self.yt_auth.get_library_upload_albums(None) + self.assertEqual(len(albums), 200) @unittest.skip("Must not have any uploaded albums to pass") def test_get_library_upload_albums_empty(self): @@ -417,22 +409,15 @@ def test_get_library_upload_albums_empty(self): self.assertEquals(len(results), 0) def test_get_library_upload_artists(self): - current_length = len(self.yt_auth.get_library_upload_artists(25)) - expected_length = current_length - while expected_length <= current_length: - expected_length = current_length + 1 - current_length = len(self.yt_auth.get_library_upload_artists(expected_length)) - expected_length -= 1 - artists = self.yt_auth.get_library_upload_artists(None) - self.assertEqual(len(artists), expected_length) + self.assertGreaterEqual(len(artists), 250) results = self.yt_auth.get_library_upload_artists(50, order='a_to_z') - self.assertGreaterEqual(len(results), current_length) + self.assertGreaterEqual(len(results), 25) results = self.yt_auth.get_library_upload_artists(50, order='z_to_a') - self.assertGreaterEqual(len(results), current_length) + self.assertGreaterEqual(len(results), 25) results = self.yt_auth.get_library_upload_artists(50, order='recently_added') - self.assertGreaterEqual(len(results), current_length) + self.assertGreaterEqual(len(results), 25) @unittest.skip("Must not have any uploaded artsts to pass") def test_get_library_upload_artists_empty(self): From ec771fc97635959c250a433c1cb21c48bf42f531 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 26 Sep 2022 12:49:43 +0200 Subject: [PATCH 068/238] get_playlist: support None limit (#275) --- tests/test.py | 5 ++++- ytmusicapi/mixins/playlists.py | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test.py b/tests/test.py index e3b44a51..32ea6a5c 100644 --- a/tests/test.py +++ b/tests/test.py @@ -329,9 +329,12 @@ def test_subscribe_artists(self): ############### def test_get_foreign_playlist(self): - playlist = self.yt.get_playlist(sample_playlist, 300) + playlist = self.yt.get_playlist(sample_playlist, limit=300) self.assertGreater(len(playlist['tracks']), 200) + playlist = self.yt.get_playlist("RDCLAK5uy_kpxnNxJpPZjLKbL9WgvrPuErWkUxMP6x4", limit=None) + self.assertGreater(len(playlist['tracks']), 100) + def test_get_owned_playlist(self): playlist = self.yt_brand.get_playlist(config['playlists']['own']) self.assertLess(len(playlist['tracks']), 100) diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 6fb0c539..2dbc36e1 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -14,7 +14,7 @@ def get_playlist(self, playlistId: str, limit: int = 100) -> Dict: Returns a list of playlist items :param playlistId: Playlist id - :param limit: How many songs to return. Default: 100 + :param limit: How many songs to return. `None` retrieves them all. Default: 100 :return: Dictionary with information about the playlist. The key ``tracks`` contains a List of playlistItem dictionaries @@ -106,6 +106,8 @@ def get_playlist(self, playlistId: str, limit: int = 100) -> Dict: playlist['tracks'] = [] if song_count > 0: playlist['tracks'].extend(parse_playlist_items(results['contents'])) + if limit is None: + limit = song_count songs_to_get = min(limit, song_count) if 'continuations' in results: From 98c4661fce78d217d8f7e8d6e4e978d141313f4c Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 26 Sep 2022 13:12:45 +0200 Subject: [PATCH 069/238] get_library_songs: add support for limit=None (closes #275) add tests for get_library_subscriptions and get_library_artists with limit=None --- tests/test.py | 19 ++++++------------- ytmusicapi/mixins/library.py | 6 +++++- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/tests/test.py b/tests/test.py index 32ea6a5c..6283a9dd 100644 --- a/tests/test.py +++ b/tests/test.py @@ -242,16 +242,13 @@ def test_get_all_library_playlists(self): self.assertEqual(len(playlists), expected_length) def test_get_library_songs(self): + self.assertRaises(Exception, self.yt_auth.get_library_songs, None, True) songs = self.yt_brand.get_library_songs(100) self.assertGreaterEqual(len(songs), 100) songs = self.yt_auth.get_library_songs(200, validate_responses=True) self.assertGreaterEqual(len(songs), 200) songs = self.yt_auth.get_library_songs(order='a_to_z') self.assertGreaterEqual(len(songs), 25) - songs = self.yt_auth.get_library_songs(order='z_to_a') - self.assertGreaterEqual(len(songs), 25) - songs = self.yt_auth.get_library_songs(order='recently_added') - self.assertGreaterEqual(len(songs), 25) def test_get_library_albums(self): albums = self.yt_auth.get_library_albums(100) @@ -268,20 +265,16 @@ def test_get_library_artists(self): self.assertGreater(len(artists), 40) artists = self.yt_brand.get_library_artists(order='a_to_z', limit=50) self.assertGreater(len(artists), 40) - artists = self.yt_brand.get_library_artists(order='z_to_a') - self.assertGreater(len(artists), 20) - artists = self.yt_brand.get_library_artists(order='recently_added') - self.assertGreater(len(artists), 20) + artists = self.yt_brand.get_library_artists(limit=None) + self.assertGreater(len(artists), 300) def test_get_library_subscriptions(self): artists = self.yt_brand.get_library_subscriptions(50) self.assertGreater(len(artists), 40) - artists = self.yt_brand.get_library_subscriptions(order='a_to_z') - self.assertGreater(len(artists), 20) artists = self.yt_brand.get_library_subscriptions(order='z_to_a') self.assertGreater(len(artists), 20) - artists = self.yt_brand.get_library_subscriptions(order='recently_added') - self.assertGreater(len(artists), 20) + artists = self.yt_brand.get_library_subscriptions(limit=None) + self.assertGreater(len(artists), 50) def test_get_liked_songs(self): songs = self.yt_brand.get_liked_songs(200) @@ -404,7 +397,7 @@ def test_get_library_upload_albums(self): self.assertGreater(len(results), 40) albums = self.yt_auth.get_library_upload_albums(None) - self.assertEqual(len(albums), 200) + self.assertGreaterEqual(len(albums), 200) @unittest.skip("Must not have any uploaded albums to pass") def test_get_library_upload_albums_empty(self): diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index f2081eed..e4481a74 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -67,6 +67,9 @@ def get_library_songs(self, request_func = lambda additionalParams: self._send_request(endpoint, body) parse_func = lambda raw_response: parse_library_songs(raw_response) + if validate_responses and limit is None: + raise Exception("Validation is not supported without a limit parameter.") + if validate_responses: validate_func = lambda parsed: validate_response(parsed, per_page, limit, 0) response = resend_request_until_parsed_response_is_valid(request_func, None, @@ -89,8 +92,9 @@ def get_library_songs(self, request_continuations_func, parse_continuations_func)) else: + remaining_limit = None if limit is None else (limit - len(songs)) songs.extend( - get_continuations(results, 'musicShelfContinuation', limit - len(songs), + get_continuations(results, 'musicShelfContinuation', remaining_limit, request_continuations_func, parse_continuations_func)) return songs From 28668901a31b9b2c6e748d6d284f0d2ec92a8d0a Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 26 Sep 2022 13:39:36 +0200 Subject: [PATCH 070/238] get_home: support watch_playlist content --- ytmusicapi/parsers/browsing.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index cf8d63fe..53c281cc 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -25,8 +25,11 @@ def parse_mixed_content(self, rows): content = None if data: page_type = nav(data, TITLE + NAVIGATION_BROWSE + PAGE_TYPE, True) - if page_type is None: # song - content = parse_song(data) + if page_type is None: # song or watch_playlist + if nav(data, NAVIGATION_WATCH_PLAYLIST_ID, True) is not None: + content = parse_watch_playlist(data) + else: + content = parse_song(data) elif page_type == "MUSIC_PAGE_TYPE_ALBUM": content = parse_album(data) elif page_type == "MUSIC_PAGE_TYPE_ARTIST": @@ -250,15 +253,14 @@ def parse_song_flat(data): def parse_video(result): runs = result['subtitle']['runs'] artists_len = get_dot_separator_index(runs) - video = { + return { 'title': nav(result, TITLE_TEXT), 'videoId': nav(result, NAVIGATION_VIDEO_ID), 'artists': parse_song_artists_runs(runs[:artists_len]), 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), - 'thumbnails': nav(result, THUMBNAIL_RENDERER, True) + 'thumbnails': nav(result, THUMBNAIL_RENDERER, True), + 'views': runs[-1]['text'].split(' ')[0] } - video['views'] = runs[-1]['text'].split(' ')[0] - return video def parse_playlist(data): @@ -287,3 +289,11 @@ def parse_related_artist(data): 'subscribers': subscribers, 'thumbnails': nav(data, THUMBNAIL_RENDERER), } + + +def parse_watch_playlist(data): + return { + 'title': nav(data, TITLE_TEXT), + 'playlistId': nav(data, TITLE + NAVIGATION_BROWSE_ID), + 'thumbnails': nav(data, THUMBNAIL_RENDERER), + } From 9b74957a1f85e9ea7d9089dae2ec790c0a594c3b Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 26 Sep 2022 14:22:04 +0200 Subject: [PATCH 071/238] tests: move some limits to test.cfg --- tests/test.cfg.example | 10 +++++++++- tests/test.py | 22 ++++++++++------------ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/test.cfg.example b/tests/test.cfg.example index e143df0f..00f9a64e 100644 --- a/tests/test.cfg.example +++ b/tests/test.cfg.example @@ -10,4 +10,12 @@ own = owned_playlist_id [uploads] file = song_in_tests_directory.mp3 private_album_id = sample_id_of_private_album -private_artist_id = sample_id_of_private_artist \ No newline at end of file +private_artist_id = sample_id_of_private_artist +private_upload_id = sample_video_id_of_private_upload + +[limits] +library_songs = 200 +library_artists = 300 +library_subscriptions = 50 +library_upload_albums = 200 +library_upload_artists = 250 diff --git a/tests/test.py b/tests/test.py index 6283a9dd..1f4e4103 100644 --- a/tests/test.py +++ b/tests/test.py @@ -1,6 +1,7 @@ import unittest import unittest.mock import configparser +import time import sys sys.path.insert(0, '..') from ytmusicapi.ytmusic import YTMusic # noqa: E402 @@ -140,7 +141,7 @@ def test_get_album(self): self.assertEqual(len(results['tracks']), 7) def test_get_song(self): - song = self.yt_auth.get_song("AjXQiKP5kMs") # private upload + song = self.yt_auth.get_song(config['uploads']['private_upload_id']) # private upload self.assertEqual(len(song), 4) song = self.yt.get_song(sample_video) self.assertGreaterEqual(len(song['streamingData']['adaptiveFormats']), 10) @@ -156,7 +157,7 @@ def test_get_lyrics(self): self.assertIsNotNone(lyrics_song["lyrics"]) self.assertIsNotNone(lyrics_song["source"]) - playlist = self.yt.get_watch_playlist("9TnpB8WgW4s") + playlist = self.yt.get_watch_playlist(config['uploads']['private_upload_id']) self.assertIsNone(playlist["lyrics"]) self.assertRaises(Exception, self.yt.get_lyrics, playlist["lyrics"]) @@ -246,7 +247,7 @@ def test_get_library_songs(self): songs = self.yt_brand.get_library_songs(100) self.assertGreaterEqual(len(songs), 100) songs = self.yt_auth.get_library_songs(200, validate_responses=True) - self.assertGreaterEqual(len(songs), 200) + self.assertGreaterEqual(len(songs), config.getint('limits', 'library_songs')) songs = self.yt_auth.get_library_songs(order='a_to_z') self.assertGreaterEqual(len(songs), 25) @@ -266,7 +267,7 @@ def test_get_library_artists(self): artists = self.yt_brand.get_library_artists(order='a_to_z', limit=50) self.assertGreater(len(artists), 40) artists = self.yt_brand.get_library_artists(limit=None) - self.assertGreater(len(artists), 300) + self.assertGreater(len(artists), config.getint('limits', 'library_artists')) def test_get_library_subscriptions(self): artists = self.yt_brand.get_library_subscriptions(50) @@ -274,7 +275,7 @@ def test_get_library_subscriptions(self): artists = self.yt_brand.get_library_subscriptions(order='z_to_a') self.assertGreater(len(artists), 20) artists = self.yt_brand.get_library_subscriptions(limit=None) - self.assertGreater(len(artists), 50) + self.assertGreater(len(artists), config.getint('limits', 'library_subscriptions')) def test_get_liked_songs(self): songs = self.yt_brand.get_liked_songs(200) @@ -363,11 +364,12 @@ def test_end2end(self): source_playlist="OLAK5uy_lGQfnMNGvYCRdDq9ZLzJV2BJL2aHQsz9Y") self.assertEqual(len(playlistId), 34, "Playlist creation failed") response = self.yt_auth.add_playlist_items( - playlistId, ['y0Hhvtmv0gk', sample_video], + playlistId, [sample_video, sample_video], source_playlist='OLAK5uy_nvjTE32aFYdFN7HCyMv3cGqD3wqBb4Jow', duplicates=True) self.assertEqual(response["status"], 'STATUS_SUCCEEDED', "Adding playlist item failed") self.assertGreater(len(response["playlistEditResults"]), 0, "Adding playlist item failed") + time.sleep(2) playlist = self.yt_auth.get_playlist(playlistId) self.assertEqual(len(playlist['tracks']), 46, "Getting playlist items failed") response = self.yt_auth.remove_playlist_items(playlistId, playlist['tracks']) @@ -397,7 +399,7 @@ def test_get_library_upload_albums(self): self.assertGreater(len(results), 40) albums = self.yt_auth.get_library_upload_albums(None) - self.assertGreaterEqual(len(albums), 200) + self.assertGreaterEqual(len(albums), config.getint('limits', 'library_upload_albums')) @unittest.skip("Must not have any uploaded albums to pass") def test_get_library_upload_albums_empty(self): @@ -406,12 +408,8 @@ def test_get_library_upload_albums_empty(self): def test_get_library_upload_artists(self): artists = self.yt_auth.get_library_upload_artists(None) - self.assertGreaterEqual(len(artists), 250) + self.assertGreaterEqual(len(artists), config.getint('limits', 'library_upload_artists')) - results = self.yt_auth.get_library_upload_artists(50, order='a_to_z') - self.assertGreaterEqual(len(results), 25) - results = self.yt_auth.get_library_upload_artists(50, order='z_to_a') - self.assertGreaterEqual(len(results), 25) results = self.yt_auth.get_library_upload_artists(50, order='recently_added') self.assertGreaterEqual(len(results), 25) From 3ea2aa2ff2361578b128bfbc8188b1b4d45af282 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 26 Sep 2022 14:36:32 +0200 Subject: [PATCH 072/238] update coverage action --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index f9e95f3f..b58a898a 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -28,7 +28,7 @@ jobs: coverage run --source=../ytmusicapi -m unittest test.py coverage xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: file: tests/coverage.xml flags: unittests From d36a01077dc909356009c93f7ebdacb0a40db93c Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 29 Sep 2022 21:11:56 +0200 Subject: [PATCH 073/238] test_get_library_playlists: add limit to cfg --- tests/test.cfg.example | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test.cfg.example b/tests/test.cfg.example index 00f9a64e..afff702b 100644 --- a/tests/test.cfg.example +++ b/tests/test.cfg.example @@ -14,6 +14,7 @@ private_artist_id = sample_id_of_private_artist private_upload_id = sample_video_id_of_private_upload [limits] +library_playlists = 100 library_songs = 200 library_artists = 300 library_subscriptions = 50 From 1cac82be212e7413e14e71c8147606530ace9fec Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 29 Sep 2022 21:13:08 +0200 Subject: [PATCH 074/238] get_library_*: fix for next YouTube update (#301) --- tests/test.py | 20 ++++++------------- ytmusicapi/mixins/library.py | 4 +--- ytmusicapi/mixins/uploads.py | 15 ++++---------- ytmusicapi/parsers/library.py | 37 +++++++++++++++++------------------ 4 files changed, 29 insertions(+), 47 deletions(-) diff --git a/tests/test.py b/tests/test.py index 1f4e4103..f444158d 100644 --- a/tests/test.py +++ b/tests/test.py @@ -231,20 +231,12 @@ def test_get_library_playlists(self): playlists = self.yt_auth.get_library_playlists(50) self.assertGreater(len(playlists), 25) - def test_get_all_library_playlists(self): - current_length = len(self.yt_auth.get_library_playlists(25)) - expected_length = current_length - while expected_length <= current_length: - expected_length = current_length + 1 - current_length = len(self.yt_auth.get_library_playlists(expected_length)) - expected_length -= 1 - playlists = self.yt_auth.get_library_playlists(None) - self.assertEqual(len(playlists), expected_length) + self.assertGreaterEqual(len(playlists), config.getint('limits', 'library_playlists')) def test_get_library_songs(self): self.assertRaises(Exception, self.yt_auth.get_library_songs, None, True) - songs = self.yt_brand.get_library_songs(100) + songs = self.yt_auth.get_library_songs(100) self.assertGreaterEqual(len(songs), 100) songs = self.yt_auth.get_library_songs(200, validate_responses=True) self.assertGreaterEqual(len(songs), config.getint('limits', 'library_songs')) @@ -262,7 +254,7 @@ def test_get_library_albums(self): self.assertGreater(len(albums), 50) def test_get_library_artists(self): - artists = self.yt_brand.get_library_artists(50) + artists = self.yt_auth.get_library_artists(50) self.assertGreater(len(artists), 40) artists = self.yt_brand.get_library_artists(order='a_to_z', limit=50) self.assertGreater(len(artists), 40) @@ -392,7 +384,7 @@ def test_get_library_upload_songs(self): @unittest.skip("Must not have any uploaded songs to pass") def test_get_library_upload_songs_empty(self): results = self.yt_auth.get_library_upload_songs(100) - self.assertEquals(len(results), 0) + self.assertEqual(len(results), 0) def test_get_library_upload_albums(self): results = self.yt_auth.get_library_upload_albums(50, order='a_to_z') @@ -404,7 +396,7 @@ def test_get_library_upload_albums(self): @unittest.skip("Must not have any uploaded albums to pass") def test_get_library_upload_albums_empty(self): results = self.yt_auth.get_library_upload_albums(100) - self.assertEquals(len(results), 0) + self.assertEqual(len(results), 0) def test_get_library_upload_artists(self): artists = self.yt_auth.get_library_upload_artists(None) @@ -416,7 +408,7 @@ def test_get_library_upload_artists(self): @unittest.skip("Must not have any uploaded artsts to pass") def test_get_library_upload_artists_empty(self): results = self.yt_auth.get_library_upload_artists(100) - self.assertEquals(len(results), 0) + self.assertEqual(len(results), 0) def test_upload_song(self): self.assertRaises(Exception, self.yt_auth.upload_song, 'song.wav') diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index e4481a74..cd3e95a1 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -26,9 +26,7 @@ def get_library_playlists(self, limit: int = 25) -> List[Dict]: endpoint = 'browse' response = self._send_request(endpoint, body) - results = find_object_by_key(nav(response, SINGLE_COLUMN_TAB + SECTION_LIST), - 'itemSectionRenderer') - results = nav(results, ITEM_SECTION + GRID) + results = get_library_contents(response, GRID) playlists = parse_content_list(results['items'][1:], parse_playlist) if 'continuations' in results: diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index d4aee56f..7f49d21a 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -7,7 +7,7 @@ from ytmusicapi.helpers import * from ytmusicapi.navigation import * from ytmusicapi.continuations import get_continuations -from ytmusicapi.parsers.library import parse_library_albums, parse_library_artists +from ytmusicapi.parsers.library import parse_library_albums, parse_library_artists, get_library_contents from ytmusicapi.parsers.albums import parse_album_header from ytmusicapi.parsers.uploads import parse_uploaded_items @@ -43,17 +43,10 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D if order is not None: body["params"] = prepare_order_params(order) response = self._send_request(endpoint, body) - results = find_object_by_key(nav(response, SINGLE_COLUMN_TAB + SECTION_LIST), - 'itemSectionRenderer') - results = nav(results, ITEM_SECTION) - if 'musicShelfRenderer' not in results: + results = get_library_contents(response, MUSIC_SHELF) + if results is None: return [] - else: - results = results['musicShelfRenderer'] - - songs = [] - - songs.extend(parse_uploaded_items(results['contents'][1:])) + songs = parse_uploaded_items(results['contents'][1:]) if 'continuations' in results: request_func = lambda additionalParams: self._send_request( diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index 5bdd884e..81a931b0 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -24,12 +24,9 @@ def parse_artists(results, uploaded=False): def parse_library_albums(response, request_func, limit): - results = find_object_by_key(nav(response, SINGLE_COLUMN_TAB + SECTION_LIST), - 'itemSectionRenderer') - results = nav(results, ITEM_SECTION) - if 'gridRenderer' not in results: + results = get_library_contents(response, GRID) + if results is None: return [] - results = nav(results, GRID) albums = parse_albums(results['items']) if 'continuations' in results: @@ -83,12 +80,9 @@ def parse_albums(results): def parse_library_artists(response, request_func, limit): - results = find_object_by_key(nav(response, SINGLE_COLUMN_TAB + SECTION_LIST), - 'itemSectionRenderer') - results = nav(results, ITEM_SECTION) - if 'musicShelfRenderer' not in results: + results = get_library_contents(response, MUSIC_SHELF) + if results is None: return [] - results = results['musicShelfRenderer'] artists = parse_artists(results['contents']) if 'continuations' in results: @@ -102,12 +96,17 @@ def parse_library_artists(response, request_func, limit): def parse_library_songs(response): - results = find_object_by_key(nav(response, SINGLE_COLUMN_TAB + SECTION_LIST), - 'itemSectionRenderer') - results = nav(results, ITEM_SECTION) - songs = {'results': [], 'parsed': []} - if 'musicShelfRenderer' in results: - songs['results'] = results['musicShelfRenderer'] - songs['parsed'] = parse_playlist_items(songs['results']['contents'][1:]) - - return songs + results = get_library_contents(response, MUSIC_SHELF) + return {'results': results, 'parsed': (parse_playlist_items(results['contents'][1:]))} + + +def get_library_contents(response, renderer): + # first 3 lines are original path prior to #301 + contents = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST, True) + if contents is None: # empty library + return None + results = find_object_by_key(contents, 'itemSectionRenderer') + if results is None: + return nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + renderer, True) + else: + return nav(results, ITEM_SECTION + renderer) From 3ab1d91a93e9c5042ff590cec1b942182df1cc2d Mon Sep 17 00:00:00 2001 From: sigma67 Date: Fri, 30 Sep 2022 09:50:25 +0200 Subject: [PATCH 075/238] get_home: fix watch playlist error --- ytmusicapi/parsers/browsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 53c281cc..83c9a6a6 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -294,6 +294,6 @@ def parse_related_artist(data): def parse_watch_playlist(data): return { 'title': nav(data, TITLE_TEXT), - 'playlistId': nav(data, TITLE + NAVIGATION_BROWSE_ID), + 'playlistId': nav(data, NAVIGATION_WATCH_PLAYLIST_ID), 'thumbnails': nav(data, THUMBNAIL_RENDERER), } From c2d5e468054a7609a93f6fe836b3bbfd53544b1d Mon Sep 17 00:00:00 2001 From: sigma67 Date: Fri, 30 Sep 2022 19:30:41 +0200 Subject: [PATCH 076/238] remove get_playlist_suggestions - add suggestions and related to get_playlist get_playlist_suggestions was not working anymore due to changes on YouTube's end calls to the playlist page on YouTube Music now also return co-dependent continuations for suggestions and related items to support both, these continuations are now part of get_playlist, but made optional through function parameters default values are set backwards-compatible, i.e. no extra requests are performed without setting the new parameters --- tests/test.py | 18 +++---- ytmusicapi/continuations.py | 18 +++++-- ytmusicapi/mixins/playlists.py | 96 ++++++++++++++++++++++++---------- ytmusicapi/navigation.py | 7 +-- 4 files changed, 94 insertions(+), 45 deletions(-) diff --git a/tests/test.py b/tests/test.py index f444158d..45d8de8b 100644 --- a/tests/test.py +++ b/tests/test.py @@ -315,21 +315,19 @@ def test_subscribe_artists(self): ############### def test_get_foreign_playlist(self): - playlist = self.yt.get_playlist(sample_playlist, limit=300) + playlist = self.yt.get_playlist(sample_playlist, limit=300, suggestions_limit=7) self.assertGreater(len(playlist['tracks']), 200) + self.assertNotIn('suggestions', playlist) - playlist = self.yt.get_playlist("RDCLAK5uy_kpxnNxJpPZjLKbL9WgvrPuErWkUxMP6x4", limit=None) + playlist = self.yt.get_playlist("RDCLAK5uy_kpxnNxJpPZjLKbL9WgvrPuErWkUxMP6x4", limit=None, related=True) self.assertGreater(len(playlist['tracks']), 100) + self.assertEqual(len(playlist['related']), 10) def test_get_owned_playlist(self): - playlist = self.yt_brand.get_playlist(config['playlists']['own']) + playlist = self.yt_brand.get_playlist(config['playlists']['own'], related=True, suggestions_limit=21) self.assertLess(len(playlist['tracks']), 100) - if not playlist['suggestions_token']: - self.skipTest("Suggestions not available") - suggestions = self.yt_brand.get_playlist_suggestions(playlist['suggestions_token']) - self.assertGreater(len(suggestions['tracks']), 5) - refresh = self.yt_brand.get_playlist_suggestions(suggestions['refresh_token']) - self.assertGreater(len(refresh['tracks']), 5) + self.assertEqual(len(playlist['suggestions']), 21) + self.assertEqual(len(playlist['related']), 10) def test_edit_playlist(self): playlist = self.yt_brand.get_playlist(config['playlists']['own']) @@ -362,7 +360,7 @@ def test_end2end(self): self.assertEqual(response["status"], 'STATUS_SUCCEEDED', "Adding playlist item failed") self.assertGreater(len(response["playlistEditResults"]), 0, "Adding playlist item failed") time.sleep(2) - playlist = self.yt_auth.get_playlist(playlistId) + playlist = self.yt_auth.get_playlist(playlistId, related=True) self.assertEqual(len(playlist['tracks']), 46, "Getting playlist items failed") response = self.yt_auth.remove_playlist_items(playlistId, playlist['tracks']) self.assertEqual(response, 'STATUS_SUCCEEDED', "Playlist item removal failed") diff --git a/ytmusicapi/continuations.py b/ytmusicapi/continuations.py index 97a48851..f0793941 100644 --- a/ytmusicapi/continuations.py +++ b/ytmusicapi/continuations.py @@ -1,10 +1,17 @@ from ytmusicapi.navigation import nav -def get_continuations(results, continuation_type, limit, request_func, parse_func, ctoken_path=""): +def get_continuations(results, + continuation_type, + limit, + request_func, + parse_func, + ctoken_path="", + reloadable=False): items = [] while 'continuations' in results and (limit is None or len(items) < limit): - additionalParams = get_continuation_params(results, ctoken_path) + additionalParams = get_reloadable_continuation_params(results) if reloadable \ + else get_continuation_params(results, ctoken_path) response = request_func(additionalParams) if 'continuationContents' in response: results = response['continuationContents'][continuation_type] @@ -46,12 +53,17 @@ def get_parsed_continuation_items(response, parse_func, continuation_type): return {'results': results, 'parsed': get_continuation_contents(results, parse_func)} -def get_continuation_params(results, ctoken_path): +def get_continuation_params(results, ctoken_path=''): ctoken = nav(results, ['continuations', 0, 'next' + ctoken_path + 'ContinuationData', 'continuation']) return get_continuation_string(ctoken) +def get_reloadable_continuation_params(results): + ctoken = nav(results, ['continuations', 0, 'reloadContinuationData', 'continuation']) + return get_continuation_string(ctoken) + + def get_continuation_string(ctoken): return "&ctoken=" + ctoken + "&continuation=" + ctoken diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 2dbc36e1..c3f2bb6d 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -2,19 +2,24 @@ from typing import Dict, Union, Tuple from ._utils import * -from ytmusicapi.continuations import get_continuations, get_continuation_string +from ytmusicapi.continuations import * from ytmusicapi.navigation import * from ytmusicapi.helpers import to_int, sum_total_duration from ytmusicapi.parsers.playlists import * +from ytmusicapi.parsers.browsing import parse_content_list, parse_playlist class PlaylistsMixin: - def get_playlist(self, playlistId: str, limit: int = 100) -> Dict: + def get_playlist(self, playlistId: str, limit: int = 100, related: bool = False, suggestions_limit: int = 0) -> Dict: """ Returns a list of playlist items :param playlistId: Playlist id :param limit: How many songs to return. `None` retrieves them all. Default: 100 + :param related: Whether to fetch 10 related playlists or not. Default: False + :param suggestions_limit: How many suggestions to return. The result is a list of + suggested playlist items (videos) contained in a "suggestions" key. + 7 items are retrieved in each internal request. Default: 0 :return: Dictionary with information about the playlist. The key ``tracks`` contains a List of playlistItem dictionaries @@ -31,6 +36,35 @@ def get_playlist(self, playlistId: str, limit: int = 100) -> Dict: "duration": "6+ hours", "duration_seconds": 52651, "trackCount": 237, + "suggestions": [ + { + "videoId": "HLCsfOykA94", + "title": "Mambo (GATTÜSO Remix)", + "artists": [{ + "name": "Nikki Vianna", + "id": "UCMW5eSIO1moVlIBLQzq4PnQ" + }], + "album": { + "name": "Mambo (GATTÜSO Remix)", + "id": "MPREb_jLeQJsd7U9w" + }, + "likeStatus": "LIKE", + "thumbnails": [...], + "isAvailable": true, + "isExplicit": false, + "duration": "3:32", + "duration_seconds": 212, + "setVideoId": "to_be_updated_by_client" + } + ], + "related": [ + { + "title": "Presenting MYRNE", + "playlistId": "RDCLAK5uy_mbdO3_xdD4NtU1rWI0OmvRSRZ8NH4uJCM", + "thumbnails": [...], + "description": "Playlist • YouTube Music" + } + ], "tracks": [ { "videoId": "bjGppZKiuFE", @@ -99,21 +133,44 @@ def get_playlist(self, playlistId: str, limit: int = 100) -> Dict: playlist['duration'] = header['secondSubtitle']['runs'][2]['text'] playlist['trackCount'] = song_count - playlist['suggestions_token'] = nav( - response, SINGLE_COLUMN_TAB + ['sectionListRenderer', 'contents', 1] + MUSIC_SHELF - + RELOAD_CONTINUATION, True) - playlist['tracks'] = [] + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) + + # suggestions and related are missing e.g. on liked songs + section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) + if 'continuations' in section_list: + additionalParams = get_continuation_params(section_list) + if own_playlist 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) + parse_func = lambda results: parse_content_list(results, parse_playlist) + playlist['related'] = get_continuation_contents(nav(continuation, CONTENT + CAROUSEL), + parse_func) + if song_count > 0: - playlist['tracks'].extend(parse_playlist_items(results['contents'])) + playlist['tracks'] = parse_playlist_items(results['contents']) if limit is None: limit = song_count songs_to_get = min(limit, song_count) + parse_func = lambda contents: parse_playlist_items(contents) if 'continuations' in results: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) - parse_func = lambda contents: parse_playlist_items(contents) playlist['tracks'].extend( get_continuations(results, 'musicPlaylistShelfContinuation', songs_to_get - len(playlist['tracks']), request_func, @@ -122,25 +179,6 @@ def get_playlist(self, playlistId: str, limit: int = 100) -> Dict: playlist['duration_seconds'] = sum_total_duration(playlist) return playlist - def get_playlist_suggestions(self, suggestions_token: str) -> Dict: - """ - Gets suggested tracks to add to a playlist. Suggestions are offered for playlists with less than 100 tracks - - :param suggestions_token: Token returned by :py:func:`get_playlist` or this function - :return: Dictionary containing suggested `tracks` and a `refresh_token` to get another set of suggestions. - For data format of tracks, check :py:func:`get_playlist` - """ - if not suggestions_token: - raise Exception('Suggestions token is None. ' - 'Please ensure the playlist is small enough to receive suggestions.') - endpoint = 'browse' - additionalParams = get_continuation_string(suggestions_token) - response = self._send_request(endpoint, {}, additionalParams) - results = nav(response, ['continuationContents', 'musicShelfContinuation']) - refresh_token = nav(results, RELOAD_CONTINUATION) - suggestions = parse_playlist_items(results['contents']) - return {'tracks': suggestions, 'refresh_token': refresh_token} - def create_playlist(self, title: str, description: str, diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 2a74fa2f..ddc01a33 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -1,10 +1,11 @@ # commonly used navigation paths +CONTENT = ['contents', 0] RUN_TEXT = ['runs', 0, 'text'] TAB_CONTENT = ['tabs', 0, 'tabRenderer', 'content'] SINGLE_COLUMN_TAB = ['contents', 'singleColumnBrowseResultsRenderer'] + TAB_CONTENT SECTION_LIST = ['sectionListRenderer', 'contents'] -SECTION_LIST_ITEM = ['sectionListRenderer', 'contents', 0] -ITEM_SECTION = ['itemSectionRenderer', 'contents', 0] +SECTION_LIST_ITEM = ['sectionListRenderer'] + CONTENT +ITEM_SECTION = ['itemSectionRenderer'] + CONTENT MUSIC_SHELF = ['musicShelfRenderer'] GRID = ['gridRenderer'] GRID_ITEMS = GRID + ['items'] @@ -52,13 +53,13 @@ BADGE_LABEL = [ 'badges', 0, 'musicInlineBadgeRenderer', 'accessibilityData', 'accessibilityData', 'label' ] -RELOAD_CONTINUATION = ['continuations', 0, 'reloadContinuationData', 'continuation'] CATEGORY_TITLE = ['musicNavigationButtonRenderer', 'buttonText'] + RUN_TEXT CATEGORY_PARAMS = ['musicNavigationButtonRenderer', 'clickCommand', 'browseEndpoint', 'params'] MRLIR = 'musicResponsiveListItemRenderer' MTRIR = 'musicTwoRowItemRenderer' TASTE_PROFILE_ITEMS = ["contents", "tastebuilderRenderer", "contents"] TASTE_PROFILE_ARTIST = ["title", "runs"] +SECTION_LIST_CONTINUATION = ['continuationContents', 'sectionListContinuation'] def nav(root, items, none_if_absent=False): From dd5d75343234aa5c98c8189bafcb88b6d7197145 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 1 Oct 2022 14:56:59 +0200 Subject: [PATCH 077/238] get_album: add other versions (closes #279) also added isExplicit label to all album type results --- tests/test.py | 9 +++++++-- ytmusicapi/mixins/browsing.py | 12 ++++++++++++ ytmusicapi/navigation.py | 6 +++--- ytmusicapi/parsers/browsing.py | 3 ++- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/test.py b/tests/test.py index 45d8de8b..d8567264 100644 --- a/tests/test.py +++ b/tests/test.py @@ -137,6 +137,7 @@ def test_get_album(self): self.assertGreaterEqual(len(results), 9) self.assertTrue(results['tracks'][0]['isExplicit']) self.assertIn('feedbackTokens', results['tracks'][0]) + self.assertEqual(len(results['other_versions']), 2) results = self.yt.get_album("MPREb_BQZvl3BFGay") self.assertEqual(len(results['tracks']), 7) @@ -319,12 +320,16 @@ def test_get_foreign_playlist(self): self.assertGreater(len(playlist['tracks']), 200) self.assertNotIn('suggestions', playlist) - playlist = self.yt.get_playlist("RDCLAK5uy_kpxnNxJpPZjLKbL9WgvrPuErWkUxMP6x4", limit=None, related=True) + playlist = self.yt.get_playlist("RDCLAK5uy_kpxnNxJpPZjLKbL9WgvrPuErWkUxMP6x4", + limit=None, + related=True) self.assertGreater(len(playlist['tracks']), 100) self.assertEqual(len(playlist['related']), 10) def test_get_owned_playlist(self): - playlist = self.yt_brand.get_playlist(config['playlists']['own'], related=True, suggestions_limit=21) + playlist = self.yt_brand.get_playlist(config['playlists']['own'], + related=True, + suggestions_limit=21) self.assertLess(len(playlist['tracks']), 100) self.assertEqual(len(playlist['suggestions']), 21) self.assertEqual(len(playlist['related']), 10) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 113b8c77..ddd4dcb9 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -392,6 +392,15 @@ def get_album(self, browseId: str) -> Dict: } } ], + "other_versions": [ + { + "title": "Revival", + "year": "Eminem", + "browseId": "MPREb_fefKFOTEZSp", + "thumbnails": [...], + "isExplicit": false + }, + ], "duration_seconds": 4657 } """ @@ -401,6 +410,9 @@ def get_album(self, browseId: str) -> Dict: album = parse_album_header(response) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) album['tracks'] = parse_playlist_items(results['contents']) + results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST + [1] + CAROUSEL, True) + if results is not None: + album['other_versions'] = parse_content_list(results['contents'], parse_album) album['duration_seconds'] = sum_total_duration(album) for i, track in enumerate(album['tracks']): album['tracks'][i]['album'] = album['title'] diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index ddc01a33..2090d592 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -50,9 +50,9 @@ THUMBNAIL_RENDERER = ['thumbnailRenderer', 'musicThumbnailRenderer'] + THUMBNAIL THUMBNAIL_CROPPED = ['thumbnail', 'croppedSquareThumbnailRenderer'] + THUMBNAIL FEEDBACK_TOKEN = ['feedbackEndpoint', 'feedbackToken'] -BADGE_LABEL = [ - 'badges', 0, 'musicInlineBadgeRenderer', 'accessibilityData', 'accessibilityData', 'label' -] +BADGE_PATH = [0, 'musicInlineBadgeRenderer', 'accessibilityData', 'accessibilityData', 'label'] +BADGE_LABEL = ['badges'] + BADGE_PATH +SUBTITLE_BADGE_LABEL = ['subtitleBadges'] + BADGE_PATH CATEGORY_TITLE = ['musicNavigationButtonRenderer', 'buttonText'] + RUN_TEXT CATEGORY_PARAMS = ['musicNavigationButtonRenderer', 'clickCommand', 'browseEndpoint', 'params'] MRLIR = 'musicResponsiveListItemRenderer' diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 83c9a6a6..49d4d302 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -205,7 +205,8 @@ def parse_album(result): 'title': nav(result, TITLE_TEXT), 'year': nav(result, SUBTITLE2, True), 'browseId': nav(result, TITLE + NAVIGATION_BROWSE_ID), - 'thumbnails': nav(result, THUMBNAIL_RENDERER) + 'thumbnails': nav(result, THUMBNAIL_RENDERER), + 'isExplicit': nav(result, SUBTITLE_BADGE_LABEL, True) is not None } From 207d7ad7140bbb50e6784dfdd29accc2148a991f Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 1 Oct 2022 15:06:56 +0200 Subject: [PATCH 078/238] add coverage for bad request exception --- tests/test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test.py b/tests/test.py index d8567264..da45b720 100644 --- a/tests/test.py +++ b/tests/test.py @@ -316,6 +316,7 @@ def test_subscribe_artists(self): ############### def test_get_foreign_playlist(self): + self.assertRaises(Exception, self.yt.get_playlist, "PLABC") playlist = self.yt.get_playlist(sample_playlist, limit=300, suggestions_limit=7) self.assertGreater(len(playlist['tracks']), 200) self.assertNotIn('suggestions', playlist) From 527fed67ffc05669de00b6ffa67c7df030976a21 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 1 Oct 2022 15:36:22 +0200 Subject: [PATCH 079/238] get_watch_playlist: add shuffle and radio parameters (closes #282) removed get_watch_playlist_shuffle in favor of the new shuffle parameter to get_watch_playlist setting the radio parameter now properly randomizes the watch playlist, as discussed in #282 --- docs/source/reference.rst | 1 - tests/test.py | 14 +++++--------- ytmusicapi/mixins/watch.py | 32 ++++++++++---------------------- 3 files changed, 15 insertions(+), 32 deletions(-) diff --git a/docs/source/reference.rst b/docs/source/reference.rst index db3d7699..981b72e4 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -43,7 +43,6 @@ Explore Watch -------- .. automethod:: YTMusic.get_watch_playlist -.. automethod:: YTMusic.get_watch_playlist_shuffle Library ------- diff --git a/tests/test.py b/tests/test.py index da45b720..6fe16e34 100644 --- a/tests/test.py +++ b/tests/test.py @@ -207,21 +207,17 @@ def test_get_charts(self): ############### def test_get_watch_playlist(self): - playlist = self.yt_auth.get_watch_playlist( - playlistId="OLAK5uy_ln_o1YXFqK4nfiNuTfhJK2XcRNCxml0fY", limit=90) + playlist = self.yt_auth.get_watch_playlist(playlistId="RDAMPLOLAK5uy_l_fKDQGOUsk8kbWsm9s86n4-nZNd2JR8Q", + radio=True, limit=90) self.assertGreaterEqual(len(playlist['tracks']), 90) playlist = self.yt_auth.get_watch_playlist("9mWr4c_ig54", limit=50) self.assertGreater(len(playlist['tracks']), 45) playlist = self.yt_auth.get_watch_playlist("UoAf_y9Ok4k") # private track self.assertGreaterEqual(len(playlist['tracks']), 25) - - def test_get_watch_playlist_shuffle(self): - playlist = self.yt.get_watch_playlist_shuffle( - playlistId="OLAK5uy_lKgoGvlrWhX0EIPavQUXxyPed8Cj38AWc") + playlist = self.yt.get_watch_playlist( + playlistId="OLAK5uy_lKgoGvlrWhX0EIPavQUXxyPed8Cj38AWc", shuffle=True) self.assertEqual(len(playlist['tracks']), 12) - - def test_get_watch_playlist_shuffle_playlist(self): - playlist = self.yt_brand.get_watch_playlist_shuffle(playlistId=config['playlists']['own']) + playlist = self.yt_brand.get_watch_playlist(playlistId=config['playlists']['own'], shuffle=True) self.assertEqual(len(playlist['tracks']), 4) ################ diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index 6a5aa856..412c89dd 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -10,7 +10,8 @@ def get_watch_playlist(self, videoId: str = None, playlistId: str = None, limit=25, - params: str = None) -> Dict[str, Union[List[Dict]]]: + radio: bool = False, + shuffle: bool = False) -> Dict[str, Union[List[Dict]]]: """ Get a watch list of tracks. This watch playlist appears when you press play on a track in YouTube Music. @@ -22,7 +23,9 @@ def get_watch_playlist(self, :param videoId: videoId of the played video :param playlistId: playlistId of the played playlist or album :param limit: minimum number of watch playlist items to return - :param params: only used internally by :py:func:`get_watch_playlist_shuffle` + :param radio: get a radio playlist (changes each time) + :param shuffle: shuffle the input playlist. only works when the playlistId parameter + is set at the same time. does not work if radio=True :return: List of watch playlist items. The counterpart key is optional and only appears if a song has a corresponding video counterpart (UI song/video switcher). @@ -108,7 +111,7 @@ def get_watch_playlist(self, body['videoId'] = videoId if not playlistId: playlistId = "RDAMVM" + videoId - if not params: + if not (radio or shuffle): body['watchEndpointMusicSupportedConfigs'] = { 'watchEndpointMusicConfig': { 'hasPersistentPlaylistPanel': True, @@ -118,8 +121,10 @@ def get_watch_playlist(self, body['playlistId'] = validate_playlist_id(playlistId) is_playlist = body['playlistId'].startswith('PL') or \ body['playlistId'].startswith('OLA') - if params: - body['params'] = params + if shuffle and playlistId is not None: + body['params'] = "wAEB8gECKAE%3D" + if radio: + body['params'] = "wAEB" endpoint = 'next' response = self._send_request(endpoint, body) watchNextRenderer = nav(response, [ @@ -152,20 +157,3 @@ def get_watch_playlist(self, playlistId=playlist, lyrics=lyrics_browse_id, related=related_browse_id) - - def get_watch_playlist_shuffle(self, - videoId: str = None, - playlistId: str = None, - limit=50) -> Dict[str, Union[List[Dict]]]: - """ - Shuffle any playlist - - :param videoId: Optional video id of the first video in the shuffled playlist - :param playlistId: Playlist id - :param limit: The number of watch playlist items to return - :return: A list of watch playlist items (see :py:func:`get_watch_playlist`) - """ - return self.get_watch_playlist(videoId=videoId, - playlistId=playlistId, - limit=limit, - params='wAEB8gECKAE%3D') From 7de7ecfcc0bfae0df6aeb9d997a0eebeb917d853 Mon Sep 17 00:00:00 2001 From: atlasrule <51425254+atlasrule@users.noreply.github.com> Date: Fri, 23 Sep 2022 08:04:45 +0300 Subject: [PATCH 080/238] Add check if absent Checking if nested objects absent solved solved my issue, #298 I'm not sure if it's suitable. --- ytmusicapi/parsers/playlists.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index c538e63d..21b40120 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -32,12 +32,13 @@ def parse_playlist_items(results, menu_entries: List[List] = None): feedback_tokens = parse_song_menu_tokens(item) # if item is not playable, the videoId was retrieved above - if 'playNavigationEndpoint' in nav(data, PLAY_BUTTON): - videoId = nav(data, - PLAY_BUTTON)['playNavigationEndpoint']['watchEndpoint']['videoId'] + if nav(data, PLAY_BUTTON, none_if_absent=True) != 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) + if 'menu' in data: + like = nav(data, MENU_LIKE_STATUS, True) title = get_item_text(data, 0) if title == 'Song deleted': From 8501f9fe08a24f18a1c228f63acbc4a36ef40faf Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 1 Oct 2022 16:36:49 +0200 Subject: [PATCH 081/238] move headers_auth.json.example to docs/source remove codecov.yml --- codecov.yml | 10 ---------- .../source/headers_auth.json.example | 0 docs/source/setup.rst | 4 ++-- 3 files changed, 2 insertions(+), 12 deletions(-) delete mode 100644 codecov.yml rename headers_auth.json.example => docs/source/headers_auth.json.example (100%) diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 4a04d4de..00000000 --- a/codecov.yml +++ /dev/null @@ -1,10 +0,0 @@ -coverage: - range: 50..90 - - status: - project: - default: off - patch: - default: off - changes: - default: off \ No newline at end of file diff --git a/headers_auth.json.example b/docs/source/headers_auth.json.example similarity index 100% rename from headers_auth.json.example rename to docs/source/headers_auth.json.example diff --git a/docs/source/setup.rst b/docs/source/setup.rst index ae0ee7aa..82db098e 100644 --- a/docs/source/setup.rst +++ b/docs/source/setup.rst @@ -43,7 +43,7 @@ To do so, follow these steps: .. container:: - - Verify that the request looks like this: **Status** 200, **Type** xhr, **Name** ``browse?...`` + - Verify that the request looks like this: **Status** 200, **Name** ``browse?...`` - Click on the Name of any matching request. In the "Headers" tab, scroll to the section "Request headers" and copy everything starting from "accept: \*/\*" to the end of the section .. raw:: html @@ -92,5 +92,5 @@ Manual file creation Alternatively, you can paste the cookie to ``headers_auth.json`` below and create your own file: -.. literalinclude:: ../../headers_auth.json.example +.. literalinclude:: headers_auth.json.example :language: JSON From 03a534f344b7a1b14167aa2384c442864aad0dd5 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 1 Oct 2022 17:05:18 +0200 Subject: [PATCH 082/238] fix internal package version --- docs/source/conf.py | 2 +- ytmusicapi/__init__.py | 10 ++++++++-- ytmusicapi/_version.py | 1 - 3 files changed, 9 insertions(+), 4 deletions(-) delete mode 100644 ytmusicapi/_version.py diff --git a/docs/source/conf.py b/docs/source/conf.py index baa9632d..6610745d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -21,7 +21,7 @@ # -- Project information ----------------------------------------------------- project = 'ytmusicapi' -copyright = '2020, sigma67' +copyright = '2022, sigma67' author = 'sigma67' # The full version, including alpha/beta/rc tags diff --git a/ytmusicapi/__init__.py b/ytmusicapi/__init__.py index 6cd910cc..eb2ffeaf 100644 --- a/ytmusicapi/__init__.py +++ b/ytmusicapi/__init__.py @@ -1,6 +1,12 @@ -from ytmusicapi._version import __version__ from ytmusicapi.ytmusic import YTMusic +from importlib.metadata import version, PackageNotFoundError -__copyright__ = 'Copyright 2020 sigma67' +try: + __version__ = version("ytmusicapi") +except PackageNotFoundError: + # package is not installed + pass + +__copyright__ = 'Copyright 2022 sigma67' __license__ = 'MIT' __title__ = 'ytmusicapi' diff --git a/ytmusicapi/_version.py b/ytmusicapi/_version.py deleted file mode 100644 index 5963297e..00000000 --- a/ytmusicapi/_version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.22.0" From 92fd2c0dd2073af6fd89c4c2757fcb4ec51f901e Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 1 Oct 2022 17:12:28 +0200 Subject: [PATCH 083/238] require Python 3.8 --- .github/workflows/coverage.yml | 2 +- README.rst | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b58a898a..e420dd03 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -13,7 +13,7 @@ jobs: - name: Setup Python uses: actions/setup-python@master with: - python-version: 3.7 + python-version: 3.x - name: Generate coverage report env: HEADERS_AUTH: ${{ secrets.HEADERS_AUTH }} diff --git a/README.rst b/README.rst index 4c0fc0c8..89dca7ab 100644 --- a/README.rst +++ b/README.rst @@ -79,7 +79,7 @@ The `tests `_ a Requirements ============== -- Python 3.7 or higher - https://www.python.org +- Python 3.8 or higher - https://www.python.org Setup and Usage =============== diff --git a/pyproject.toml b/pyproject.toml index 0bcc0584..e4e5ecf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ytmusicapi" description = "Unofficial API for YouTube Music" -requires-python = ">=3.7" +requires-python = ">=3.8" authors=[{name = "sigma67", email= "ytmusicapi@gmail.com"}] license={file="LICENSE"} classifiers = [ From 0e0a6d1186f660ffffa8ad150923b2dc22e9bded Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 1 Oct 2022 17:44:09 +0200 Subject: [PATCH 084/238] update README.rst --- README.rst | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/README.rst b/README.rst index 89dca7ab..e1d0f491 100644 --- a/README.rst +++ b/README.rst @@ -29,6 +29,9 @@ It emulates YouTube Music web client requests using the user's cookie data for a Features -------- +At this point ytmusicapi supports nearly all content interactions in the YouTube Music web app. +If you find something missing or broken, feel free to create an `issue `_ + | **Browsing**: * search (including all filters) @@ -36,7 +39,7 @@ Features * get user information (videos, playlists) * get albums * get song metadata -* get watch playlists (playlist that appears when you press play in YouTube Music) +* get watch playlists (next songs when you press play/radio/shuffle in YouTube Music) * get song lyrics | **Exploring music**: @@ -48,17 +51,19 @@ Features * get library contents: playlists, songs, artists, albums and subscriptions * add/remove library content: rate songs, albums and playlists, subscribe/unsubscribe artists +* get and modify play history | **Playlists**: * create and delete playlists * modify playlists: edit metadata, add/move/remove tracks * get playlist contents +* get playlist suggestions | **Uploads**: -* Upload songs and remove them again -* List uploaded songs, artists and albums +* upload songs and remove them again +* list uploaded songs, artists and albums Usage @@ -67,10 +72,10 @@ Usage from ytmusicapi import YTMusic - ytmusic = YTMusic('headers_auth.json') - playlistId = ytmusic.create_playlist('test', 'test description') - search_results = ytmusic.search('Oasis Wonderwall') - ytmusic.add_playlist_items(playlistId, [search_results[0]['videoId']]) + yt = YTMusic('headers_auth.json') + playlistId = yt.create_playlist('test', 'test description') + search_results = yt.search('Oasis Wonderwall') + yt.add_playlist_items(playlistId, [search_results[0]['videoId']]) The `tests `_ are also a great source of usage examples. From feac9501d9e49a09285fa60d919f1b5f97ed1b69 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 1 Oct 2022 18:03:24 +0200 Subject: [PATCH 085/238] add .readthedocs.yml --- .readthedocs.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .readthedocs.yml diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 00000000..2cab60b2 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,14 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.10" + +python: + install: + - method: pip + path: . + +sphinx: + configuration: docs/source/conf.py \ No newline at end of file From 0d25af194721eee1d3b0b166d973f9d5939ffeca Mon Sep 17 00:00:00 2001 From: Benjamin Ryzman Date: Mon, 3 Oct 2022 15:12:06 +0200 Subject: [PATCH 086/238] Add playlistId key to the album dicts in parse_album --- ytmusicapi/navigation.py | 1 + ytmusicapi/parsers/library.py | 1 + 2 files changed, 2 insertions(+) diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 2090d592..8f3826e5 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -60,6 +60,7 @@ TASTE_PROFILE_ITEMS = ["contents", "tastebuilderRenderer", "contents"] TASTE_PROFILE_ARTIST = ["title", "runs"] SECTION_LIST_CONTINUATION = ['continuationContents', 'sectionListContinuation'] +MENU_PLAYLIST_ID = MENU_ITEMS + [0 + 'menuNavigationItemRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID def nav(root, items, none_if_absent=False): diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index 81a931b0..bf4fb29f 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -45,6 +45,7 @@ def parse_albums(results): data = result[MTRIR] album = {} album['browseId'] = nav(data, TITLE + NAVIGATION_BROWSE_ID) + album['playlistId'] = nav(data, MENU_PLAYLIST_ID) album['title'] = nav(data, TITLE_TEXT) album['thumbnails'] = nav(data, THUMBNAIL_RENDERER) From 56796e6785857d977d42651dc8d4c027710853f0 Mon Sep 17 00:00:00 2001 From: Benjamin Ryzman Date: Tue, 4 Oct 2022 11:34:54 +0200 Subject: [PATCH 087/238] Fix typo in MENU_PLAYLIST_ID --- ytmusicapi/navigation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 8f3826e5..7952669c 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -60,7 +60,7 @@ TASTE_PROFILE_ITEMS = ["contents", "tastebuilderRenderer", "contents"] TASTE_PROFILE_ARTIST = ["title", "runs"] SECTION_LIST_CONTINUATION = ['continuationContents', 'sectionListContinuation'] -MENU_PLAYLIST_ID = MENU_ITEMS + [0 + 'menuNavigationItemRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID +MENU_PLAYLIST_ID = MENU_ITEMS + [0, 'menuNavigationItemRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID def nav(root, items, none_if_absent=False): From 4815fc521e5994dbdf3794307387bb6f02e58489 Mon Sep 17 00:00:00 2001 From: Benjamin Ryzman Date: Tue, 4 Oct 2022 11:35:26 +0200 Subject: [PATCH 088/238] Amend get_library_albums docstring --- ytmusicapi/mixins/library.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index cd3e95a1..f9419247 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -109,6 +109,7 @@ def get_library_albums(self, limit: int = 25, order: str = None) -> List[Dict]: { "browseId": "MPREb_G8AiyN7RvFg", + "playlistId": "OLAK5uy_lKgoGvlrWhX0EIPavQUXxyPed8Cj38AWc", "title": "Beautiful", "type": "Album", "thumbnails": [...], From c37a2eca2ca10fe6318fc57ce8205c0e296937d6 Mon Sep 17 00:00:00 2001 From: Benjamin Ryzman Date: Tue, 4 Oct 2022 12:30:15 +0200 Subject: [PATCH 089/238] Add playlistId key presence unittest --- tests/test.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test.py b/tests/test.py index 6fe16e34..b4d5f33e 100644 --- a/tests/test.py +++ b/tests/test.py @@ -243,6 +243,8 @@ def test_get_library_songs(self): def test_get_library_albums(self): albums = self.yt_auth.get_library_albums(100) self.assertGreater(len(albums), 50) + for album in albums: + self.assertIn('playlistId',album) albums = self.yt_brand.get_library_albums(100, order='a_to_z') self.assertGreater(len(albums), 50) albums = self.yt_brand.get_library_albums(100, order='z_to_a') From 081a64d1b9992d70d1361d6a9cc83388eeec1855 Mon Sep 17 00:00:00 2001 From: Benjamin Ryzman Date: Tue, 4 Oct 2022 12:38:23 +0200 Subject: [PATCH 090/238] Add none_if_absent param to playlistId key in parse_albums --- ytmusicapi/parsers/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index bf4fb29f..f1de2473 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -45,7 +45,7 @@ def parse_albums(results): data = result[MTRIR] album = {} album['browseId'] = nav(data, TITLE + NAVIGATION_BROWSE_ID) - album['playlistId'] = nav(data, MENU_PLAYLIST_ID) + album['playlistId'] = nav(data, MENU_PLAYLIST_ID, none_if_absent=True) album['title'] = nav(data, TITLE_TEXT) album['thumbnails'] = nav(data, THUMBNAIL_RENDERER) From a406dbcf9586201d718c773ea6c599c368b55cfb Mon Sep 17 00:00:00 2001 From: JohnHKoh Date: Thu, 13 Oct 2022 09:58:22 -0400 Subject: [PATCH 091/238] Remove quotes from cookies --- ytmusicapi/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index e174e709..a8a43114 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -42,7 +42,7 @@ def get_visitor_id(request_func): def sapisid_from_cookie(raw_cookie): cookie = SimpleCookie() - cookie.load(raw_cookie) + cookie.load(raw_cookie.replace("\"", "")) return cookie['__Secure-3PAPISID'].value From 4221cb9c1e05ecf2c13156d9a74c66f2c8842b43 Mon Sep 17 00:00:00 2001 From: Lucas Macedo Date: Tue, 18 Oct 2022 15:07:35 -0300 Subject: [PATCH 092/238] return videoType on playlist items --- ytmusicapi/mixins/playlists.py | 1 + ytmusicapi/parsers/playlists.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index c3f2bb6d..7cf12bad 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -88,6 +88,7 @@ def get_playlist(self, playlistId: str, limit: int = 100, related: bool = False, "thumbnails": [...], "isAvailable": True, "isExplicit": False, + "videoType": "MUSIC_VIDEO_TYPE_OMV", "feedbackTokens": { "add": "AB9zfpJxtvrU...", "remove": "AB9zfpKTyZ..." diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index 21b40120..fd7b6487 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -66,6 +66,8 @@ def parse_playlist_items(results, menu_entries: List[List] = None): isExplicit = nav(data, BADGE_LABEL, True) is not None + videoType = nav(data, MENU_ITEMS + [0, 'menuNavigationItemRenderer', 'navigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) + song = { 'videoId': videoId, 'title': title, @@ -74,7 +76,8 @@ def parse_playlist_items(results, menu_entries: List[List] = None): 'likeStatus': like, 'thumbnails': thumbnails, 'isAvailable': isAvailable, - 'isExplicit': isExplicit + 'isExplicit': isExplicit, + 'videoType': videoType } if duration: song['duration'] = duration From 2965004ba0813c6059b2f6479cf02f09bc7853c8 Mon Sep 17 00:00:00 2001 From: terry3041 Date: Thu, 3 Nov 2022 17:54:14 +0800 Subject: [PATCH 093/238] Add localization for zh_TW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zh_TW Chinese (Traditional) 中文 (繁體) --- ytmusicapi/locales/zh_TW/LC_MESSAGES/base.mo | Bin 0 -> 728 bytes ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po | 58 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 ytmusicapi/locales/zh_TW/LC_MESSAGES/base.mo create mode 100644 ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po diff --git a/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.mo b/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.mo new file mode 100644 index 0000000000000000000000000000000000000000..8184310dff474b63eb6d79acd2012d0e2f3ada3d GIT binary patch literal 728 zcmYk2Pfyf97{*7%f7OF{k%NhIG%Laxos24Q2>ev$^T5;KZJ^wz0=xqCIP>D<(fjAX zi_m|Wu>l+b-^}{k8TT^&%=jzgA0TYm7cBeP6rju^^PYl|k{P6AW|`|)^8fpy!CZZT zBN_{z3sei}Qtty{4>a-7f zFLu(kZ@tx5U{*TY?WD2UUEV+Z{wZxPC+p3$^%=ory3s~yYgMAR@gr$$p=AGSXXVW? F{sF}F!sh@0 literal 0 HcmV?d00001 diff --git a/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po b/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po new file mode 100644 index 00000000..218f1336 --- /dev/null +++ b/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po @@ -0,0 +1,58 @@ +# Translations for ytmusicapi +# Copyright (C) 2021 sigma67 +# This file is distributed under the same license as ytmusicapi +# sigma67 +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: parsers/browsing.py:22 +msgid "artist" +msgstr "藝人" + +#: parsers/browsing.py:22 +msgid "playlist" +msgstr "播放清單" + +#: parsers/browsing.py:23 +msgid "song" +msgstr "歌曲" + +#: parsers/browsing.py:23 +msgid "video" +msgstr "影片" + +#: parsers/browsing.py:24 +msgid "station" +msgstr "電台" + +#: parsers/browsing.py:128 +msgid "albums" +msgstr "專輯" + +#: parsers/browsing.py:128 +msgid "singles" +msgstr "單曲" + +#: parsers/browsing.py:128 +msgid "videos" +msgstr "影片" + +#: parsers/browsing.py:128 +msgid "playlists" +msgstr "精選收錄" + +#: parsers/browsing.py:128 +msgid "related" +msgstr "粉絲可能也會喜歡" From d48951f00fc2d66192181a38ee1c344e770cf65d Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 3 Nov 2022 17:17:19 +0100 Subject: [PATCH 094/238] fix run parsing - artist mistaken as views (closes #313) --- ytmusicapi/parsers/songs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/parsers/songs.py b/ytmusicapi/parsers/songs.py index a8cfc153..f679bbeb 100644 --- a/ytmusicapi/parsers/songs.py +++ b/ytmusicapi/parsers/songs.py @@ -38,7 +38,7 @@ def parse_song_runs(runs): else: # note: YT uses non-breaking space \xa0 to separate number and magnitude - if re.match(r"^\d([^ ])* [^ ]*$", text): + if re.match(r"^\d([^ ])* [^ ]*$", text) and i > 0: parsed['views'] = text.split(' ')[0] elif re.match(r"^(\d+:)*\d+:\d+$", text): From 2344a1715b93a9d3cd5b375f56183cb26caec8c5 Mon Sep 17 00:00:00 2001 From: bretsky <11529474+bretsky@users.noreply.github.com> Date: Tue, 22 Nov 2022 17:54:06 -0800 Subject: [PATCH 095/238] Fix KeyError when playlist is empty --- ytmusicapi/helpers.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index a8a43114..95c752a3 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -66,6 +66,8 @@ def to_int(string): def sum_total_duration(item): + if 'tracks' not in item: + return 0 return sum([ track['duration_seconds'] if 'duration_seconds' in track else 0 for track in item['tracks'] ]) From c91f47e828643f3bc75bbbad7a24da95f3182203 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 30 Nov 2022 14:51:14 +0100 Subject: [PATCH 096/238] get_song: add playbackTracking key (OzymandiasTheGreat/mopidy-ytmusic#58) --- tests/test.py | 11 ++++++----- ytmusicapi/mixins/browsing.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/tests/test.py b/tests/test.py index b4d5f33e..62c67ba2 100644 --- a/tests/test.py +++ b/tests/test.py @@ -143,7 +143,7 @@ def test_get_album(self): def test_get_song(self): song = self.yt_auth.get_song(config['uploads']['private_upload_id']) # private upload - self.assertEqual(len(song), 4) + self.assertEqual(len(song), 5) song = self.yt.get_song(sample_video) self.assertGreaterEqual(len(song['streamingData']['adaptiveFormats']), 10) @@ -207,8 +207,8 @@ def test_get_charts(self): ############### def test_get_watch_playlist(self): - playlist = self.yt_auth.get_watch_playlist(playlistId="RDAMPLOLAK5uy_l_fKDQGOUsk8kbWsm9s86n4-nZNd2JR8Q", - radio=True, limit=90) + playlist = self.yt_auth.get_watch_playlist( + playlistId="RDAMPLOLAK5uy_l_fKDQGOUsk8kbWsm9s86n4-nZNd2JR8Q", radio=True, limit=90) self.assertGreaterEqual(len(playlist['tracks']), 90) playlist = self.yt_auth.get_watch_playlist("9mWr4c_ig54", limit=50) self.assertGreater(len(playlist['tracks']), 45) @@ -217,7 +217,8 @@ def test_get_watch_playlist(self): playlist = self.yt.get_watch_playlist( playlistId="OLAK5uy_lKgoGvlrWhX0EIPavQUXxyPed8Cj38AWc", shuffle=True) self.assertEqual(len(playlist['tracks']), 12) - playlist = self.yt_brand.get_watch_playlist(playlistId=config['playlists']['own'], shuffle=True) + playlist = self.yt_brand.get_watch_playlist(playlistId=config['playlists']['own'], + shuffle=True) self.assertEqual(len(playlist['tracks']), 4) ################ @@ -244,7 +245,7 @@ def test_get_library_albums(self): albums = self.yt_auth.get_library_albums(100) self.assertGreater(len(albums), 50) for album in albums: - self.assertIn('playlistId',album) + self.assertIn('playlistId', album) albums = self.yt_brand.get_library_albums(100, order='a_to_z') self.assertGreater(len(albums), 50) albums = self.yt_brand.get_library_albums(100, order='z_to_a') diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index ddd4dcb9..dd1b6314 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -478,6 +478,33 @@ def get_song(self, videoId: str, signatureTimestamp: int = None) -> Dict: } ] }, + "playbackTracking": { + "videostatsPlaybackUrl": { + "baseUrl": "https://s.youtube.com/api/stats/playback?cl=491307275&docid=AjXQiKP5kMs&ei=Nl2HY-6MH5WE8gPjnYnoDg&fexp=1714242%2C9405963%2C23804281%2C23858057%2C23880830%2C23880833%2C23882685%2C23918597%2C23934970%2C23946420%2C23966208%2C23983296%2C23998056%2C24001373%2C24002022%2C24002025%2C24004644%2C24007246%2C24034168%2C24036947%2C24077241%2C24080738%2C24120820%2C24135310%2C24135692%2C24140247%2C24161116%2C24162919%2C24164186%2C24169501%2C24175560%2C24181174%2C24187043%2C24187377%2C24187854%2C24191629%2C24197450%2C24199724%2C24200839%2C24209349%2C24211178%2C24217535%2C24219713%2C24224266%2C24241378%2C24248091%2C24248956%2C24255543%2C24255545%2C24262346%2C24263796%2C24265426%2C24267564%2C24268142%2C24279196%2C24280220%2C24283426%2C24283493%2C24287327%2C24288045%2C24290971%2C24292955%2C24293803%2C24299747%2C24390674%2C24391018%2C24391537%2C24391709%2C24392268%2C24392363%2C24392401%2C24401557%2C24402891%2C24403794%2C24406605%2C24407200%2C24407665%2C24407914%2C24408220%2C24411766%2C24413105%2C24413820%2C24414162%2C24415866%2C24416354%2C24420756%2C24421162%2C24425861%2C24428962%2C24590921%2C39322504%2C39322574%2C39322694%2C39322707&ns=yt&plid=AAXusD4TIOMjS5N4&el=detailpage&len=246&of=Jx1iRksbq-rB9N1KSijZLQ&osid=MWU2NzBjYTI%3AAOeUNAagU8UyWDUJIki5raGHy29-60-yTA&uga=29&vm=CAEQABgEOjJBUEV3RWxUNmYzMXNMMC1MYVpCVnRZTmZWMWw1OWVZX2ZOcUtCSkphQ245VFZwOXdTQWJbQVBta0tETEpWNXI1SlNIWEJERXdHeFhXZVllNXBUemt5UHR4WWZEVzFDblFUSmdla3BKX2R0dXk3bzFORWNBZmU5YmpYZnlzb3doUE5UU0FoVGRWa0xIaXJqSWgB", + "headers": [ + { + "headerType": "USER_AUTH" + }, + { + "headerType": "VISITOR_ID" + }, + { + "headerType": "PLUS_PAGE_ID" + } + ] + }, + "videostatsDelayplayUrl": {(as above)}, + "videostatsWatchtimeUrl": {(as above)}, + "ptrackingUrl": {(as above)}, + "qoeUrl": {(as above)}, + "atrUrl": {(as above)}, + "videostatsScheduledFlushWalltimeSeconds": [ + 10, + 20, + 30 + ], + "videostatsDefaultFlushIntervalSeconds": 40 + }, "videoDetails": { "videoId": "AjXQiKP5kMs", "title": "Sparks", @@ -577,7 +604,9 @@ def get_song(self, videoId: str, signatureTimestamp: int = None) -> Dict: "video_id": videoId } response = self._send_request(endpoint, params) - keys = ['videoDetails', 'playabilityStatus', 'streamingData', 'microformat'] + keys = [ + 'videoDetails', 'playabilityStatus', 'streamingData', 'microformat', 'playbackTracking' + ] for k in list(response.keys()): if k not in keys: del response[k] From d2d66f8dfebdfa614b6173969e152fa4ffc0edec Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 30 Nov 2022 14:52:05 +0100 Subject: [PATCH 097/238] tests: fix sample video broken by YTM content update --- tests/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test.py b/tests/test.py index 62c67ba2..07729b57 100644 --- a/tests/test.py +++ b/tests/test.py @@ -10,7 +10,7 @@ config.read('./test.cfg', 'utf-8') sample_album = "MPREb_4pL8gzRtw1p" # Eminem - Revival -sample_video = "tGWs0xKwhag" # Oasis - Wonderwall (Remastered) +sample_video = "hpSrLjc5SMs" # Oasis - Wonderwall sample_playlist = "PL6bPxvf5dW5clc3y9wAoslzqUrmkZ5c-u" # very large playlist From 02e32896169bcb98bf25ea57f57c3dc34a50ee13 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 7 Dec 2022 17:39:26 +0100 Subject: [PATCH 098/238] library: fix for new response structure and empty libraries (#320) --- ytmusicapi/mixins/library.py | 2 ++ ytmusicapi/navigation.py | 4 +++- ytmusicapi/parsers/library.py | 32 +++++++++++++++++++++++--------- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index f9419247..25aaacf2 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -77,6 +77,8 @@ def get_library_songs(self, results = response['results'] songs = response['parsed'] + if songs is None: + return [] if 'continuations' in results: request_continuations_func = lambda additionalParams: self._send_request( diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 7952669c..25ad1d31 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -2,7 +2,9 @@ CONTENT = ['contents', 0] RUN_TEXT = ['runs', 0, 'text'] TAB_CONTENT = ['tabs', 0, 'tabRenderer', 'content'] -SINGLE_COLUMN_TAB = ['contents', 'singleColumnBrowseResultsRenderer'] + TAB_CONTENT +TAB_1_CONTENT = ['tabs', 1, 'tabRenderer', 'content'] +SINGLE_COLUMN = ['contents', 'singleColumnBrowseResultsRenderer'] +SINGLE_COLUMN_TAB = SINGLE_COLUMN + TAB_CONTENT SECTION_LIST = ['sectionListRenderer', 'contents'] SECTION_LIST_ITEM = ['sectionListRenderer'] + CONTENT ITEM_SECTION = ['itemSectionRenderer'] + CONTENT diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index f1de2473..8aef4db8 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -98,16 +98,30 @@ def parse_library_artists(response, request_func, limit): def parse_library_songs(response): results = get_library_contents(response, MUSIC_SHELF) - return {'results': results, 'parsed': (parse_playlist_items(results['contents'][1:]))} + return { + 'results': results, + 'parsed': parse_playlist_items(results['contents'][1:]) if results else results + } def get_library_contents(response, renderer): - # first 3 lines are original path prior to #301 - contents = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST, True) - if contents is None: # empty library - return None - results = find_object_by_key(contents, 'itemSectionRenderer') - if results is None: - return nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + renderer, True) + """ + Find library contents. This function is a bit messy now + as it is supporting two different response types. Can be + cleaned up once all users are migrated to the new responses. + :param response: ytmusicapi response + :param renderer: GRID or MUSIC_SHELF + :return: library contents or None + """ + section = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST, True) + contents = None + if section is None: # empty library + contents = nav(response, SINGLE_COLUMN + TAB_1_CONTENT + SECTION_LIST_ITEM + renderer, + True) else: - return nav(results, ITEM_SECTION + renderer) + results = find_object_by_key(section, 'itemSectionRenderer') + if results is None: + contents = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + renderer, True) + else: + contents = nav(results, ITEM_SECTION + renderer, True) + return contents From d0ec2bbf7253251f1a89b734db4df192f14881c1 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 7 Dec 2022 18:07:47 +0100 Subject: [PATCH 099/238] feature: add_history_item (closes #295) (OzymandiasTheGreat/mopidy-ytmusic/issues/58) --- docs/source/reference.rst | 3 ++- tests/test.py | 10 ++++++---- ytmusicapi/helpers.py | 2 +- ytmusicapi/mixins/browsing.py | 6 +++--- ytmusicapi/mixins/library.py | 15 +++++++++++++++ ytmusicapi/ytmusic.py | 6 +++--- 6 files changed, 30 insertions(+), 12 deletions(-) diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 981b72e4..8d8a6cef 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -53,6 +53,7 @@ Library .. automethod:: YTMusic.get_library_subscriptions .. automethod:: YTMusic.get_liked_songs .. automethod:: YTMusic.get_history +.. automethod:: YTMusic.add_history_item .. automethod:: YTMusic.remove_history_items .. automethod:: YTMusic.rate_song .. automethod:: YTMusic.edit_song_library_status @@ -77,4 +78,4 @@ Uploads .. automethod:: YTMusic.get_library_upload_artist .. automethod:: YTMusic.get_library_upload_album .. automethod:: YTMusic.upload_song -.. automethod:: YTMusic.delete_upload_entity \ No newline at end of file +.. automethod:: YTMusic.delete_upload_entity diff --git a/tests/test.py b/tests/test.py index 07729b57..39755801 100644 --- a/tests/test.py +++ b/tests/test.py @@ -277,11 +277,13 @@ def test_get_history(self): songs = self.yt_auth.get_history() self.assertGreater(len(songs), 0) - @unittest.skip - def test_remove_history_items(self): + def test_manipulate_history_items(self): + song = self.yt_auth.get_song(sample_video) + response = self.yt_auth.add_history_item(song) + self.assertEqual(response.status_code, 204) songs = self.yt_auth.get_history() - response = self.yt_auth.remove_history_items( - [songs[0]['feedbackToken'], songs[1]['feedbackToken']]) + self.assertGreater(len(songs), 0) + response = self.yt_auth.remove_history_items([songs[0]['feedbackToken']]) self.assertIn('feedbackResponses', response) def test_rate_song(self): diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index 95c752a3..10b60c1e 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -32,7 +32,7 @@ def initialize_context(): def get_visitor_id(request_func): response = request_func(YTM_DOMAIN) - matches = re.findall(r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;', response) + matches = re.findall(r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;', response.text) visitor_id = "" if len(matches) > 0: ytcfg = json.loads(matches[0]) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index dd1b6314..292c1702 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -338,7 +338,7 @@ def get_album_browse_id(self, audioPlaylistId: str): """ params = {"list": audioPlaylistId} response = self._send_get_request(YTM_DOMAIN + "/playlist", params) - matches = re.findall(r"\"MPRE.+?\"", response) + matches = re.findall(r"\"MPRE.+?\"", response.text) browse_id = None if len(matches) > 0: browse_id = matches[0].encode('utf8').decode('unicode-escape').strip('"') @@ -728,7 +728,7 @@ def get_basejs_url(self): :return: URL to `base.js` """ response = self._send_get_request(url=YTM_DOMAIN) - match = re.search(r'jsUrl"\s*:\s*"([^"]+)"', response) + match = re.search(r'jsUrl"\s*:\s*"([^"]+)"', response.text) if match is None: raise Exception("Could not identify the URL for base.js player.") @@ -746,7 +746,7 @@ def get_signatureTimestamp(self, url: str = None) -> int: if url is None: url = self.get_basejs_url() response = self._send_get_request(url=url) - match = re.search(r"signatureTimestamp[:=](\d+)", response) + match = re.search(r"signatureTimestamp[:=](\d+)", response.text) if match is None: raise Exception("Unable to identify the signatureTimestamp.") diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index 25aaacf2..eab1a8d4 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -1,3 +1,4 @@ +from random import randint from ytmusicapi.continuations import * from ._utils import * from ytmusicapi.parsers.browsing import * @@ -217,6 +218,20 @@ def get_history(self) -> List[Dict]: return songs + def add_history_item(self, song): + """ + Add an item to the account's history using the playbackTracking URI + obtained from :py:func:`get_song`. + + :param song: Dictionary as returned by :py:func:`get_song` + :return: Full response. response.status_code is 204 if successful + """ + url = song["playbackTracking"]["videostatsPlaybackUrl"]["baseUrl"] + CPNA = ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + cpn = "".join((CPNA[randint(0, 256) & 63] for _ in range(0, 16))) + params = {"ver": 2, "c": "WEB_REMIX", "cpn": cpn} + return self._send_get_request(url, params) + def remove_history_items(self, feedbackTokens: List[str]) -> Dict: # pragma: no cover """ Remove an item from the account's history. This method does currently not work with brand accounts diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 80b8c935..b1df4268 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -104,8 +104,8 @@ def __init__(self, locale_dir = os.path.abspath(os.path.dirname(__file__)) + os.sep + 'locales' supported_languages = [f for f in next(os.walk(locale_dir))[1]] if language not in supported_languages: - raise Exception("Language not supported. Supported languages are " + - (', '.join(supported_languages)) + ".") + raise Exception("Language not supported. Supported languages are " + + (', '.join(supported_languages)) + ".") self.language = language try: locale.setlocale(locale.LC_ALL, self.language) @@ -150,7 +150,7 @@ def _send_get_request(self, url: str, params: Dict = None): headers=self.headers, proxies=self.proxies, cookies=self.cookies) - return response.text + return response def _check_auth(self): if not self.auth: From 4594a748e33947e8bc248841bcac3158dd773539 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 12 Dec 2022 15:02:10 +0100 Subject: [PATCH 100/238] improve test coverage --- tests/test.cfg.example | 2 + tests/test.py | 30 +++--- ytmusicapi/parsers/playlists.py | 164 ++++++++++++++++---------------- 3 files changed, 101 insertions(+), 95 deletions(-) diff --git a/tests/test.cfg.example b/tests/test.cfg.example index afff702b..d2dd8e9c 100644 --- a/tests/test.cfg.example +++ b/tests/test.cfg.example @@ -1,6 +1,8 @@ [auth] brand_account = 101234229123420379537 +brand_account_empty = 1123456629123420379537 headers = headers_auth_json_as_string +headers_empty = headers_account_with_empty_library_as_json_as_string headers_file = ./headers_auth.json headers_raw = raw_headers_pasted_from_browser diff --git a/tests/test.py b/tests/test.py index 39755801..25b6398a 100644 --- a/tests/test.py +++ b/tests/test.py @@ -20,6 +20,8 @@ def setUpClass(cls): cls.yt = YTMusic(requests_session=False) cls.yt_auth = YTMusic(config['auth']['headers_file']) cls.yt_brand = YTMusic(config['auth']['headers'], config['auth']['brand_account']) + cls.yt_empty = YTMusic(config['auth']['headers_empty'], + config['auth']['brand_account_empty']) def test_init(self): self.assertRaises(Exception, YTMusic, "{}") @@ -232,6 +234,9 @@ def test_get_library_playlists(self): playlists = self.yt_auth.get_library_playlists(None) self.assertGreaterEqual(len(playlists), config.getint('limits', 'library_playlists')) + playlists = self.yt_empty.get_library_playlists() + self.assertEqual(len(playlists), 0) + def test_get_library_songs(self): self.assertRaises(Exception, self.yt_auth.get_library_songs, None, True) songs = self.yt_auth.get_library_songs(100) @@ -240,6 +245,8 @@ def test_get_library_songs(self): self.assertGreaterEqual(len(songs), config.getint('limits', 'library_songs')) songs = self.yt_auth.get_library_songs(order='a_to_z') self.assertGreaterEqual(len(songs), 25) + songs = self.yt_empty.get_library_songs() + self.assertEqual(len(songs), 0) def test_get_library_albums(self): albums = self.yt_auth.get_library_albums(100) @@ -252,6 +259,8 @@ def test_get_library_albums(self): self.assertGreater(len(albums), 50) albums = self.yt_brand.get_library_albums(100, order='recently_added') self.assertGreater(len(albums), 50) + albums = self.yt_empty.get_library_albums() + self.assertEqual(len(albums), 0) def test_get_library_artists(self): artists = self.yt_auth.get_library_artists(50) @@ -260,6 +269,8 @@ def test_get_library_artists(self): self.assertGreater(len(artists), 40) artists = self.yt_brand.get_library_artists(limit=None) self.assertGreater(len(artists), config.getint('limits', 'library_artists')) + artists = self.yt_empty.get_library_artists() + self.assertEqual(len(artists), 0) def test_get_library_subscriptions(self): artists = self.yt_brand.get_library_subscriptions(50) @@ -268,10 +279,14 @@ def test_get_library_subscriptions(self): self.assertGreater(len(artists), 20) artists = self.yt_brand.get_library_subscriptions(limit=None) self.assertGreater(len(artists), config.getint('limits', 'library_subscriptions')) + artists = self.yt_empty.get_library_subscriptions() + self.assertEqual(len(artists), 0) def test_get_liked_songs(self): songs = self.yt_brand.get_liked_songs(200) self.assertGreater(len(songs['tracks']), 100) + songs = self.yt_empty.get_liked_songs() + self.assertEqual(songs['trackCount'], 0) def test_get_history(self): songs = self.yt_auth.get_history() @@ -383,12 +398,7 @@ def test_get_library_upload_songs(self): results = self.yt_auth.get_library_upload_songs(50, order='z_to_a') self.assertGreater(len(results), 25) - # songs = self.yt_auth.get_library_upload_songs(None) - # self.assertEqual(len(songs), 1000) - - @unittest.skip("Must not have any uploaded songs to pass") - def test_get_library_upload_songs_empty(self): - results = self.yt_auth.get_library_upload_songs(100) + results = self.yt_empty.get_library_upload_songs(100) self.assertEqual(len(results), 0) def test_get_library_upload_albums(self): @@ -398,9 +408,7 @@ def test_get_library_upload_albums(self): albums = self.yt_auth.get_library_upload_albums(None) self.assertGreaterEqual(len(albums), config.getint('limits', 'library_upload_albums')) - @unittest.skip("Must not have any uploaded albums to pass") - def test_get_library_upload_albums_empty(self): - results = self.yt_auth.get_library_upload_albums(100) + results = self.yt_empty.get_library_upload_albums(100) self.assertEqual(len(results), 0) def test_get_library_upload_artists(self): @@ -410,9 +418,7 @@ def test_get_library_upload_artists(self): results = self.yt_auth.get_library_upload_artists(50, order='recently_added') self.assertGreaterEqual(len(results), 25) - @unittest.skip("Must not have any uploaded artsts to pass") - def test_get_library_upload_artists_empty(self): - results = self.yt_auth.get_library_upload_artists(100) + results = self.yt_empty.get_library_upload_artists(100) self.assertEqual(len(results), 0) def test_upload_song(self): diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index fd7b6487..3552bb83 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -12,89 +12,87 @@ def parse_playlist_items(results, menu_entries: List[List] = None): continue data = result[MRLIR] - try: - videoId = setVideoId = None - like = None - feedback_tokens = 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 = menu_service['playlistEditEndpoint']['actions'][0][ - 'setVideoId'] - videoId = menu_service['playlistEditEndpoint']['actions'][0][ - 'removedVideoId'] - - if TOGGLE_MENU in item: - feedback_tokens = parse_song_menu_tokens(item) - - # if item is not playable, the videoId was retrieved above - if nav(data, PLAY_BUTTON, none_if_absent=True) != 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 - - artists = parse_song_artists(data, 1) - - album = parse_song_album(data, 2) - - 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, 'menuNavigationItemRenderer', 'navigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) - - song = { - 'videoId': videoId, - 'title': title, - 'artists': artists, - 'album': album, - 'likeStatus': like, - 'thumbnails': thumbnails, - 'isAvailable': isAvailable, - 'isExplicit': isExplicit, - 'videoType': videoType - } - 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) - - songs.append(song) - - except Exception as e: - print("Item " + str(count) + ": " + str(e)) + videoId = setVideoId = None + like = None + feedback_tokens = 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 = menu_service['playlistEditEndpoint']['actions'][0][ + 'setVideoId'] + videoId = menu_service['playlistEditEndpoint']['actions'][0][ + 'removedVideoId'] + + if TOGGLE_MENU in item: + feedback_tokens = parse_song_menu_tokens(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 + + artists = parse_song_artists(data, 1) + + album = parse_song_album(data, 2) + + 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, 'menuNavigationItemRenderer', 'navigationEndpoint'] + + NAVIGATION_VIDEO_TYPE, True) + + song = { + 'videoId': videoId, + 'title': title, + 'artists': artists, + 'album': album, + 'likeStatus': like, + 'thumbnails': thumbnails, + 'isAvailable': isAvailable, + 'isExplicit': isExplicit, + 'videoType': videoType + } + 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) + + songs.append(song) return songs From 08cc109fc8e0c1e488927e89f5a820826a0b0737 Mon Sep 17 00:00:00 2001 From: Sheikh Haziq Date: Sun, 1 Jan 2023 09:59:03 +0530 Subject: [PATCH 101/238] Russian language added --- ytmusicapi/locales/de/LC_MESSAGES/base.mo | Bin 769 -> 728 bytes ytmusicapi/locales/en/LC_MESSAGES/base.mo | Bin 431 -> 390 bytes ytmusicapi/locales/es/LC_MESSAGES/base.mo | Bin 803 -> 762 bytes ytmusicapi/locales/fr/LC_MESSAGES/base.mo | Bin 646 -> 605 bytes ytmusicapi/locales/it/LC_MESSAGES/base.mo | Bin 688 -> 647 bytes ytmusicapi/locales/ja/LC_MESSAGES/base.mo | Bin 823 -> 782 bytes ytmusicapi/locales/ru/LC_MESSAGES/base.mo | Bin 0 -> 749 bytes ytmusicapi/locales/ru/LC_MESSAGES/base.po | 58 ++++++++++++++++++++++ 8 files changed, 58 insertions(+) create mode 100644 ytmusicapi/locales/ru/LC_MESSAGES/base.mo create mode 100644 ytmusicapi/locales/ru/LC_MESSAGES/base.po diff --git a/ytmusicapi/locales/de/LC_MESSAGES/base.mo b/ytmusicapi/locales/de/LC_MESSAGES/base.mo index 6efa513cedb08be550bab27b61e039228ea724b8..11bb509fa4940a5b008a57d6dde3fefd3f670c2a 100644 GIT binary patch delta 100 zcmZo X(riHb2$X*s%D*`AGcB-DYCfguKnSs54@<}xuba02PYK$-(cuZ8lr0cklP{{WC?2h#U}G)Vn( zAk7A(-$MCcp!{DGPhL<9(RD6LO)SaG&(n2DEJ?LeFfuSQ)HN{HH8N2!G`2D{);2IS LFxWhe(TNcN=?EEG diff --git a/ytmusicapi/locales/en/LC_MESSAGES/base.mo b/ytmusicapi/locales/en/LC_MESSAGES/base.mo index ec8d56d408894938a284a03dc566d81b53f1870b..41f2c1aa9f9fca57e2f4e595fd8725a7e87435ea 100644 GIT binary patch delta 26 gcmZ3_+{QdXhgY1DfgzHCfk6a_3nyB}PTcRSVH|E=o--$;{8wbxABqwNfxLFf!CNFxE9P TQ7|;NGBnmUFf=fjIQcUG@^cVk diff --git a/ytmusicapi/locales/es/LC_MESSAGES/base.mo b/ytmusicapi/locales/es/LC_MESSAGES/base.mo index b0702d23e865b794b9d4eb8f31e1d2762d272f24..e103c16a8b9a79e585c9e7b6c1d55688600c927d 100644 GIT binary patch delta 101 zcmZ3?_KS5wNtiez149fDb1*P4lrS+cumkB@AT0)@TY|w< delta 140 zcmeyxx|nT3Ntgp8149fDb1*P4%w=L=Ur Pv9^Jsfx+f!j46x&Y#SD@ diff --git a/ytmusicapi/locales/fr/LC_MESSAGES/base.mo b/ytmusicapi/locales/fr/LC_MESSAGES/base.mo index ccf64021d618e342630428f51d101dfbf65e4ecc..7ac8523fb5f5bd60627aaaa17d27f5b505c4877e 100644 GIT binary patch delta 76 zcmZo;y~{Eo##Wq>f#Ej;0|Pq{3o|h=Z~$p(AT0}|Re&@rkamL79zdE6$oB)%AoZaW MXU^Z8$f(K)07zsAdjJ3c delta 115 zcmcc1(#AR=#@2z6f#Ej;0|Pq{+b}UOZ~$o+AT0}|eStJ9kj{qEg+Q7O$S()dAoX<< zXU6T@h5i0?Kck MxN_s>W=35`08_^aEdT%j delta 123 zcmZo?-M~5_#npk4fk76CSs54@;+Pm1*nxBkkmdl=IY3$#NS8vzCj)7ay4g_qg;4&g zi7Pj%h3GmLr6!hS=I80UB$lLFDHs_T8R{As>l&FT7#dp{8fzOE8W?O2XEb0000ur3 AOaK4? diff --git a/ytmusicapi/locales/ja/LC_MESSAGES/base.mo b/ytmusicapi/locales/ja/LC_MESSAGES/base.mo index b014e60372aabfd9c151e7ce243c1209ccf32cae..a62e4328574e4b38837bd523e14f9dba9c8058c8 100644 GIT binary patch delta 100 zcmdna*2gxXBvhP{fguKnc^DWNN|+cJgn)D-l%4{mmjh`Aj0}tnbq$PljZ72_jjargwG9jn L3^q?=tY8EH3(6Jn diff --git a/ytmusicapi/locales/ru/LC_MESSAGES/base.mo b/ytmusicapi/locales/ru/LC_MESSAGES/base.mo new file mode 100644 index 0000000000000000000000000000000000000000..6134e72dc903681292a83bddd79f82b0e5dee2c8 GIT binary patch literal 749 zcmZXPO=}ZD7{^DgZ>xwc2p$CYD9FTZq7YZSRBI_TG?cA)@G{Mg>&kW}%uI^CnM%Dx z$Vu>1h}K49W9rS5hna&{@mqNHKj}&fKI}8U{Xg@(&99lMHw1PLeg$se)9@c~Zx@dd zatfR-c^NzdeHAA;dio|&-c6deNVu>`5nCZo&8?_%)ftPtXS);Yr>St zwIS_-Z5Bs=G*P@@hNnd5A}hEiT8fNl!%Qv(*~n8a4^B;*NLRe6xN%x>bvUC8iBc6cZX- zNQKHJCbAC8I88WPKceGMb%MrXWBzdXY$XcrNnyA^kIgoQ82;Scz$0^UCu}`sN^^5} zHF-emhk1V16RzmIXvq|Bj>Gy|Zh}X2feWq0Y5@pV%Fw?PA0}aW60!bL^fSlE1-y;mQC2 literal 0 HcmV?d00001 diff --git a/ytmusicapi/locales/ru/LC_MESSAGES/base.po b/ytmusicapi/locales/ru/LC_MESSAGES/base.po new file mode 100644 index 00000000..370a6e44 --- /dev/null +++ b/ytmusicapi/locales/ru/LC_MESSAGES/base.po @@ -0,0 +1,58 @@ +# Translations for ytmusicapi +# Copyright (C) 2021 sigma67 +# This file is distributed under the same license as ytmusicapi +# sigma67 +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"PO-Revision-Date: 2023-01-01 09:48+0530\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ru\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.2.2\n" + +#: parsers/browsing.py:22 +msgid "artist" +msgstr "художник" + +#: parsers/browsing.py:22 +msgid "playlist" +msgstr "плейлист" + +#: parsers/browsing.py:23 +msgid "song" +msgstr "песня" + +#: parsers/browsing.py:23 +msgid "video" +msgstr "видео" + +#: parsers/browsing.py:24 +msgid "station" +msgstr "станция" + +#: parsers/browsing.py:128 +msgid "albums" +msgstr "альбомы" + +#: parsers/browsing.py:128 +msgid "singles" +msgstr "синглы" + +#: parsers/browsing.py:128 +msgid "videos" +msgstr "ролики" + +#: parsers/browsing.py:128 +msgid "playlists" +msgstr "плейлисты" + +#: parsers/browsing.py:128 +msgid "related" +msgstr "связанные с" From e6f182bbc68e77e0781b98f62f655887408ae12f Mon Sep 17 00:00:00 2001 From: Sheikh Haziq Date: Sun, 1 Jan 2023 11:10:40 +0530 Subject: [PATCH 102/238] Hindi language added --- ytmusicapi/locales/hi/LC_MESSAGES/base.mo | Bin 0 -> 786 bytes ytmusicapi/locales/hi/LC_MESSAGES/base.po | 58 ++++++++++++++++++++++ ytmusicapi/locales/pt/LC_MESSAGES/base.mo | Bin 0 -> 337 bytes ytmusicapi/locales/pt/LC_MESSAGES/base.po | 58 ++++++++++++++++++++++ 4 files changed, 116 insertions(+) create mode 100644 ytmusicapi/locales/hi/LC_MESSAGES/base.mo create mode 100644 ytmusicapi/locales/hi/LC_MESSAGES/base.po create mode 100644 ytmusicapi/locales/pt/LC_MESSAGES/base.mo create mode 100644 ytmusicapi/locales/pt/LC_MESSAGES/base.po diff --git a/ytmusicapi/locales/hi/LC_MESSAGES/base.mo b/ytmusicapi/locales/hi/LC_MESSAGES/base.mo new file mode 100644 index 0000000000000000000000000000000000000000..a133337cb3a039587a01022117da4a8a501025f7 GIT binary patch literal 786 zcmaivziSjh6vs!6zg7#ADxlz&0hx1e&p=qQiN+Afkt1xx!e(+iZbmjU$IM(Xo!D52 zXcH_&*h7&JMNm>{WhYqM$ov8JminE!m81}S?Ay=#{oeeTn%E(bGtdiA3!Q|1g?c%6 zgpd>9WW#Cj6#R?eX>bNS38oD*a032(<1d28;je&x&Kmd&yaS#E^P_}Jfebtk+J?`- zY52RK-}9y6H_+FAZ}_v}ZxADczzjVR=x_1&9)o)EH+b= +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"PO-Revision-Date: 2023-01-01 11:04+0530\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: hi\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.2.2\n" + +#: parsers/browsing.py:22 +msgid "artist" +msgstr "कलाकार" + +#: parsers/browsing.py:22 +msgid "playlist" +msgstr "प्लेलिस्ट" + +#: parsers/browsing.py:23 +msgid "song" +msgstr "गीत" + +#: parsers/browsing.py:23 +msgid "video" +msgstr "वीडियो" + +#: parsers/browsing.py:24 +msgid "station" +msgstr "स्टेशन" + +#: parsers/browsing.py:128 +msgid "albums" +msgstr "एलबम" + +#: parsers/browsing.py:128 +msgid "singles" +msgstr "एकल" + +#: parsers/browsing.py:128 +msgid "videos" +msgstr "वीडियो" + +#: parsers/browsing.py:128 +msgid "playlists" +msgstr "प्लेलिस्ट" + +#: parsers/browsing.py:128 +msgid "related" +msgstr "सम्बंधित" diff --git a/ytmusicapi/locales/pt/LC_MESSAGES/base.mo b/ytmusicapi/locales/pt/LC_MESSAGES/base.mo new file mode 100644 index 0000000000000000000000000000000000000000..6c5906d1cd061dff54de8b533942893de34efc9e GIT binary patch literal 337 zcmYL@Jx{|h5Qd9j%E-dP;DHUUz!pqFHI3Uw*h!U-O0b#M1fyU_j*H-j@b~yFTo(FD zk8Zg4bkFbc(a#8TfSe*{$RTop42h8wT;AXuI{#UD_pUbq(k-mD?~SvRtk~?4EjU^8 zqD=EFDs<<30NFQY3lF=dhsseBt#T;zrx|V_Q9)Dk#909{hlG)3PGx%joM$`|st-_k zW&2hI=P8-jLXeC}P9|KkR7_ct6ud0&v1*&0YBW?@eNZA;wx|b_i4fD)jGb@x9W;=s z +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: parsers/browsing.py:22 +msgid "artist" +msgstr "" + +#: parsers/browsing.py:22 +msgid "playlist" +msgstr "" + +#: parsers/browsing.py:23 +msgid "song" +msgstr "" + +#: parsers/browsing.py:23 +msgid "video" +msgstr "" + +#: parsers/browsing.py:24 +msgid "station" +msgstr "" + +#: parsers/browsing.py:128 +msgid "albums" +msgstr "" + +#: parsers/browsing.py:128 +msgid "singles" +msgstr "" + +#: parsers/browsing.py:128 +msgid "videos" +msgstr "" + +#: parsers/browsing.py:128 +msgid "playlists" +msgstr "" + +#: parsers/browsing.py:128 +msgid "related" +msgstr "" From 14f9cfb1bb5003a5882e8ca700e34887cea84caa Mon Sep 17 00:00:00 2001 From: Sheikh Haziq Date: Sun, 1 Jan 2023 12:20:12 +0530 Subject: [PATCH 103/238] added portuguese support --- ytmusicapi/locales/pt/LC_MESSAGES/base.mo | Bin 337 -> 712 bytes ytmusicapi/locales/pt/LC_MESSAGES/base.po | 32 +++++++++++----------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/ytmusicapi/locales/pt/LC_MESSAGES/base.mo b/ytmusicapi/locales/pt/LC_MESSAGES/base.mo index 6c5906d1cd061dff54de8b533942893de34efc9e..65af57fa55a919ff9e197b1d691bec0884aa2a4e 100644 GIT binary patch literal 712 zcmZXQL2K1O5XV=oTI0nEiid)GER=obMDhrcQ3$Sd$6_yLSFeFS5l z&tU9xKE&U_sP|*Y{{kV|8ID~|0>)Y5yf@&{VokKD73aFJ*sC*HpDyN}Nf)#atf}Q$ zef8j(qgn=1%xhECAtzk>8$o6l~X3oB&GKhU8UXkaYRcUe-baCpYtruCL$f<;o~eHZ*8VqlQcPyK8V6e<1vMGs63FSTF8n- zL2`a2&Uq7(>Hc*0viW$FCeN)2WT0`@ph=+j;R!CO%~M{!mCjRmc3iv^+n4{vUT?^W zT~k_xJLi1+REOlX*ds%(A7W-n>A)wWY?LLe{em5uiycZ97bv+R?x=BAE!y+;tL?M? kzjR!0i7iNFvD!M2cGk0b`_t=EGJ;ZHzHGmBtM1_0Kfo!efB*mh delta 235 zcmX@XdXXvSo)F7a1|VPrVi_P-0b*t#)&XJ=umEClprj>`2C0FAfTH}Y)Z`Lf&lKIT z)S}|d{5&g#07qwUM|W3+FxQ}9Pk+D31&mttk*nEz_413-5o)XyxO_c*T@fxaocxZ_n~?zkBW*c_ diff --git a/ytmusicapi/locales/pt/LC_MESSAGES/base.po b/ytmusicapi/locales/pt/LC_MESSAGES/base.po index 9b1dfc2f..3ec9d22a 100644 --- a/ytmusicapi/locales/pt/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/pt/LC_MESSAGES/base.po @@ -3,56 +3,56 @@ # This file is distributed under the same license as ytmusicapi # sigma67 # -#, fuzzy msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" +"Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-03-24 13:13+0100\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" +"PO-Revision-Date: 2023-01-01 12:15+0530\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: pt\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.2.2\n" #: parsers/browsing.py:22 msgid "artist" -msgstr "" +msgstr "artista" #: parsers/browsing.py:22 msgid "playlist" -msgstr "" +msgstr "lista de reprodução" #: parsers/browsing.py:23 msgid "song" -msgstr "" +msgstr "música" #: parsers/browsing.py:23 msgid "video" -msgstr "" +msgstr "vídeo" #: parsers/browsing.py:24 msgid "station" -msgstr "" +msgstr "estação" #: parsers/browsing.py:128 msgid "albums" -msgstr "" +msgstr "álbuns" #: parsers/browsing.py:128 msgid "singles" -msgstr "" +msgstr "solteiros" #: parsers/browsing.py:128 msgid "videos" -msgstr "" +msgstr "vídeos" #: parsers/browsing.py:128 msgid "playlists" -msgstr "" +msgstr "listas de reprodução" #: parsers/browsing.py:128 msgid "related" -msgstr "" +msgstr "relacionado" From 6b428caf3eafe58415b1e70a010ee8ec2d688424 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 1 Jan 2023 21:18:37 +0100 Subject: [PATCH 104/238] remove old code for runs parsing (closes #334) affects get_library_albums, get_library_upload_albums, get_artist_albums --- ytmusicapi/parsers/library.py | 27 +++------------------------ 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index 8aef4db8..1fa35c13 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -1,4 +1,5 @@ from .playlists import parse_playlist_items +from .songs import parse_song_runs from ._utils import * from ytmusicapi.continuations import get_continuations @@ -50,30 +51,8 @@ def parse_albums(results): album['thumbnails'] = nav(data, THUMBNAIL_RENDERER) if 'runs' in data['subtitle']: - run_count = len(data['subtitle']['runs']) - has_artists = False - if run_count == 1: - album['year'] = nav(data, SUBTITLE) - else: - album['type'] = nav(data, SUBTITLE) - - if run_count == 3: - if nav(data, SUBTITLE2).isdigit(): - album['year'] = nav(data, SUBTITLE2) - else: - has_artists = True - - elif run_count > 3: - album['year'] = nav(data, SUBTITLE3) - has_artists = True - - if has_artists: - subtitle = data['subtitle']['runs'][2] - album['artists'] = [] - album['artists'].append({ - 'name': subtitle['text'], - 'id': nav(subtitle, NAVIGATION_BROWSE_ID, True) - }) + album['type'] = nav(data, SUBTITLE) + album.update(parse_song_runs(data['subtitle']['runs'][2:])) albums.append(album) From 1ed903c8e58215abfbe59c9c6f97d48b971d7925 Mon Sep 17 00:00:00 2001 From: Sheikh Haziq Date: Mon, 2 Jan 2023 13:08:51 +0530 Subject: [PATCH 105/238] Turkish language added --- ytmusicapi/locales/tr/LC_MESSAGES/base.mo | Bin 0 -> 699 bytes ytmusicapi/locales/tr/LC_MESSAGES/base.po | 58 ++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 ytmusicapi/locales/tr/LC_MESSAGES/base.mo create mode 100644 ytmusicapi/locales/tr/LC_MESSAGES/base.po diff --git a/ytmusicapi/locales/tr/LC_MESSAGES/base.mo b/ytmusicapi/locales/tr/LC_MESSAGES/base.mo new file mode 100644 index 0000000000000000000000000000000000000000..d6c83cdd67aa8cc7a06d4cd079d985db5cccf73e GIT binary patch literal 699 zcmY+Azi$&U6vquMP!0*Tofwd~5eYe5E&^4DSV~J(sZpcqiWm^mOYU(saqP%;3i%H@ zBQY^_VCcdEGh67!-@w1Y%Gb%EwoiKg_-nsk_Mi3T&jNM_ejgs-Yw+LjXsg$RxCyQ` zya%p8KLBro55Zet-mn0dp*I`71Kxn%2V@GtlVjQQpD1>b^qp^v~-@I!Nc0>*xy zz}V+YqrZZ&?zcw&08w&o*v@MwV4Nl9FTtb58KT8nG5^2C_s@-Ve=_odTu?rUv8JiM zyz#=Zroff(%G8>@@YdAA2MWp>ajYt~i`9#sv+sEj*+6ky%@2dD`w2+0#ealTMcGkq@$TWIU#@4w-vo>Iu~> zOQz8!U8KP!-QDi?Me}wmOI}zL*r3LAj3$9UhR1lMGSAY%5joG{`C<7|Zd}C0Uhmk+ zZ8NYH-dv;`eHD_|a)%AO#SlHql?rL6mACRl;0tFzN1C1RWGKweetj3SGtwhU<6p8@ iS0n +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"PO-Revision-Date: 2023-01-02 13:06+0530\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: tr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.2.2\n" + +#: parsers/browsing.py:22 +msgid "artist" +msgstr "sanatçı" + +#: parsers/browsing.py:22 +msgid "playlist" +msgstr "çalma listesi" + +#: parsers/browsing.py:23 +msgid "song" +msgstr "şarkı" + +#: parsers/browsing.py:23 +msgid "video" +msgstr "video" + +#: parsers/browsing.py:24 +msgid "station" +msgstr "istasyon" + +#: parsers/browsing.py:128 +msgid "albums" +msgstr "albümler" + +#: parsers/browsing.py:128 +msgid "singles" +msgstr "bekarlar" + +#: parsers/browsing.py:128 +msgid "videos" +msgstr "videolar" + +#: parsers/browsing.py:128 +msgid "playlists" +msgstr "çalma listeleri" + +#: parsers/browsing.py:128 +msgid "related" +msgstr "ilişkili" From 41530515d7cbabfa49a78963e6a247688abecc97 Mon Sep 17 00:00:00 2001 From: Sheikh Haziq Date: Mon, 2 Jan 2023 21:02:14 +0530 Subject: [PATCH 106/238] Chinese added --- ytmusicapi/locales/zh/LC_MESSAGES/base.mo | Bin 0 -> 737 bytes ytmusicapi/locales/zh/LC_MESSAGES/base.pot | 58 +++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 ytmusicapi/locales/zh/LC_MESSAGES/base.mo create mode 100644 ytmusicapi/locales/zh/LC_MESSAGES/base.pot diff --git a/ytmusicapi/locales/zh/LC_MESSAGES/base.mo b/ytmusicapi/locales/zh/LC_MESSAGES/base.mo new file mode 100644 index 0000000000000000000000000000000000000000..5e45e20917e4d683b8f22a094ec743d6925880fc GIT binary patch literal 737 zcmYk2&1(}u7{*7fU#kb}sRzLUFM>?8)j+ydY0|DSm}Emt3KlQZWZbT7cEZf0SWg8( zEhshGs`!Zo(St>)2(@TDdF#!a2Tz+c`7ioTx+OmF%x_+1=ACEe-_gD$1Udvh23FvH z@CI1x$S#ET0sBJ^01rTY9C#3T61X2Y6w(CtL7fQeByca(HcPHLjDCp%dTMB&1L{~7M*twSWEZT(wTLx9qIRXMZKw7 zMItJcD;27Gbk_TSB9UMoRjiDp>pLEkNOIpnQc>k{AI-RB#GY`QM!0Z#dmv3Fm9ctH7>t}T#<;JT4_D7tA)8{i z)M@xC*FTR-(^N>NE))xwNUXO{N4&uVvHTJ*!_S#GHsvY<1#3^*jXLVQ{Th6K6s)hK z*5j4dVxxKgS^M2bw7J^o%*_MlgSFSqh0pCfKij{a2R~jkpT25+T1L%>ORblysI~Gy TV{`FabKwhWf4JLOuJ2$AlcvTz literal 0 HcmV?d00001 diff --git a/ytmusicapi/locales/zh/LC_MESSAGES/base.pot b/ytmusicapi/locales/zh/LC_MESSAGES/base.pot new file mode 100644 index 00000000..07c7e7cb --- /dev/null +++ b/ytmusicapi/locales/zh/LC_MESSAGES/base.pot @@ -0,0 +1,58 @@ +# Translations for ytmusicapi +# Copyright (C) 2021 sigma67 +# This file is distributed under the same license as ytmusicapi +# sigma67 +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Bruce Zhang \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" + +#: parsers/browsing.py:22 +msgid "artist" +msgstr "音乐人" + +#: parsers/browsing.py:22 +msgid "playlist" +msgstr "播放列表" + +#: parsers/browsing.py:23 +msgid "song" +msgstr "歌曲" + +#: parsers/browsing.py:23 +msgid "video" +msgstr "视频" + +#: parsers/browsing.py:24 +msgid "station" +msgstr "电台" + +#: parsers/browsing.py:128 +msgid "albums" +msgstr "专辑" + +#: parsers/browsing.py:128 +msgid "singles" +msgstr "单曲" + +#: parsers/browsing.py:128 +msgid "videos" +msgstr "视频" + +#: parsers/browsing.py:128 +msgid "playlists" +msgstr "精选" + +#: parsers/browsing.py:128 +msgid "related" +msgstr "粉丝可能还会喜欢" From 9415b264e4085c19cccb4412bc141e17e3678214 Mon Sep 17 00:00:00 2001 From: Sheikh Haziq Date: Mon, 2 Jan 2023 22:20:36 +0530 Subject: [PATCH 107/238] Added Arabic and Urdu --- ytmusicapi/locales/ar/LC_MESSAGES/base.mo | Bin 0 -> 751 bytes ytmusicapi/locales/ar/LC_MESSAGES/base.po | 58 ++++++++++++++++++++++ ytmusicapi/locales/ur/LC_MESSAGES/base.mo | Bin 0 -> 718 bytes ytmusicapi/locales/ur/LC_MESSAGES/base.po | 58 ++++++++++++++++++++++ 4 files changed, 116 insertions(+) create mode 100644 ytmusicapi/locales/ar/LC_MESSAGES/base.mo create mode 100644 ytmusicapi/locales/ar/LC_MESSAGES/base.po create mode 100644 ytmusicapi/locales/ur/LC_MESSAGES/base.mo create mode 100644 ytmusicapi/locales/ur/LC_MESSAGES/base.po diff --git a/ytmusicapi/locales/ar/LC_MESSAGES/base.mo b/ytmusicapi/locales/ar/LC_MESSAGES/base.mo new file mode 100644 index 0000000000000000000000000000000000000000..b750b12bba71d0fa6b54214fb4c8ffc092ded1bb GIT binary patch literal 751 zcmZ9H&uddb5XV=ozn(v^1)+lA9tGL>@}f|lc&XM>XlN*@c<{2#Tc1x}cf;9rtU2^Ux)D3)};* zf$zbq;1L*sCt#TWW1@e7H=#XH^L*4Vn(EN#Au}-Df|<6uj>H ZA>wFx +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"PO-Revision-Date: 2023-01-02 22:14+0530\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ar\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.2.2\n" + +#: parsers/browsing.py:22 +msgid "artist" +msgstr "فنان" + +#: parsers/browsing.py:22 +msgid "playlist" +msgstr "قائمةالتشغيل" + +#: parsers/browsing.py:23 +msgid "song" +msgstr "أغنية" + +#: parsers/browsing.py:23 +msgid "video" +msgstr "فيديو" + +#: parsers/browsing.py:24 +msgid "station" +msgstr "محطة" + +#: parsers/browsing.py:128 +msgid "albums" +msgstr "ألبومات" + +#: parsers/browsing.py:128 +msgid "singles" +msgstr "الفردي" + +#: parsers/browsing.py:128 +msgid "videos" +msgstr "أشرطة فيديو" + +#: parsers/browsing.py:128 +msgid "playlists" +msgstr "قوائم التشغيل" + +#: parsers/browsing.py:128 +msgid "related" +msgstr "ذات صلة" diff --git a/ytmusicapi/locales/ur/LC_MESSAGES/base.mo b/ytmusicapi/locales/ur/LC_MESSAGES/base.mo new file mode 100644 index 0000000000000000000000000000000000000000..6822aec6f6498df47ea2188928944385df321659 GIT binary patch literal 718 zcmY+9&ubGw6vs!czg9)-$wR?C3NmrCp%7NQ)YejHXee9pAjmX3u3NSvk@BRTh3HWb#^*hN*SgdM^cv{;Y=D2j*PsW+mnVD!-hti%?}EGF zZLnYFPe3>KeHlM3@3tcUR!f+z!s%&Tg-N=Keo6mTz{pHDStR z+K_h6_VVeACW`0G@Pz10q&e3_OOX<7n8~CdJ6Xcz0)@b5_r$0FkGO=UK>LU-!)J0$xJ-;Tkn|C+&tTe zU(otRotyQRE4n0FGQpoCzrL9n?-gC +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"PO-Revision-Date: 2023-01-02 22:18+0530\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: ur\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.2.2\n" + +#: parsers/browsing.py:22 +msgid "artist" +msgstr "فنکار" + +#: parsers/browsing.py:22 +msgid "playlist" +msgstr "پلے لسٹ" + +#: parsers/browsing.py:23 +msgid "song" +msgstr "نغمہ" + +#: parsers/browsing.py:23 +msgid "video" +msgstr "ویڈیو" + +#: parsers/browsing.py:24 +msgid "station" +msgstr "اسٹیشن" + +#: parsers/browsing.py:128 +msgid "albums" +msgstr "البمز" + +#: parsers/browsing.py:128 +msgid "singles" +msgstr "سنگلز" + +#: parsers/browsing.py:128 +msgid "videos" +msgstr "ویڈیوز" + +#: parsers/browsing.py:128 +msgid "playlists" +msgstr "پلے لسٹس" + +#: parsers/browsing.py:128 +msgid "related" +msgstr "متعلقہ" From 3e37eb8bd0efac265b0d652616a02aaad911b6e0 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 15 Jan 2023 15:59:48 +0100 Subject: [PATCH 108/238] Revert "Chinese added" This reverts commit 41530515d7cbabfa49a78963e6a247688abecc97. --- ytmusicapi/locales/zh/LC_MESSAGES/base.mo | Bin 737 -> 0 bytes ytmusicapi/locales/zh/LC_MESSAGES/base.pot | 58 --------------------- 2 files changed, 58 deletions(-) delete mode 100644 ytmusicapi/locales/zh/LC_MESSAGES/base.mo delete mode 100644 ytmusicapi/locales/zh/LC_MESSAGES/base.pot diff --git a/ytmusicapi/locales/zh/LC_MESSAGES/base.mo b/ytmusicapi/locales/zh/LC_MESSAGES/base.mo deleted file mode 100644 index 5e45e20917e4d683b8f22a094ec743d6925880fc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 737 zcmYk2&1(}u7{*7fU#kb}sRzLUFM>?8)j+ydY0|DSm}Emt3KlQZWZbT7cEZf0SWg8( zEhshGs`!Zo(St>)2(@TDdF#!a2Tz+c`7ioTx+OmF%x_+1=ACEe-_gD$1Udvh23FvH z@CI1x$S#ET0sBJ^01rTY9C#3T61X2Y6w(CtL7fQeByca(HcPHLjDCp%dTMB&1L{~7M*twSWEZT(wTLx9qIRXMZKw7 zMItJcD;27Gbk_TSB9UMoRjiDp>pLEkNOIpnQc>k{AI-RB#GY`QM!0Z#dmv3Fm9ctH7>t}T#<;JT4_D7tA)8{i z)M@xC*FTR-(^N>NE))xwNUXO{N4&uVvHTJ*!_S#GHsvY<1#3^*jXLVQ{Th6K6s)hK z*5j4dVxxKgS^M2bw7J^o%*_MlgSFSqh0pCfKij{a2R~jkpT25+T1L%>ORblysI~Gy TV{`FabKwhWf4JLOuJ2$AlcvTz diff --git a/ytmusicapi/locales/zh/LC_MESSAGES/base.pot b/ytmusicapi/locales/zh/LC_MESSAGES/base.pot deleted file mode 100644 index 07c7e7cb..00000000 --- a/ytmusicapi/locales/zh/LC_MESSAGES/base.pot +++ /dev/null @@ -1,58 +0,0 @@ -# Translations for ytmusicapi -# Copyright (C) 2021 sigma67 -# This file is distributed under the same license as ytmusicapi -# sigma67 -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: Bruce Zhang \n" -"Language-Team: LANGUAGE \n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" - -#: parsers/browsing.py:22 -msgid "artist" -msgstr "音乐人" - -#: parsers/browsing.py:22 -msgid "playlist" -msgstr "播放列表" - -#: parsers/browsing.py:23 -msgid "song" -msgstr "歌曲" - -#: parsers/browsing.py:23 -msgid "video" -msgstr "视频" - -#: parsers/browsing.py:24 -msgid "station" -msgstr "电台" - -#: parsers/browsing.py:128 -msgid "albums" -msgstr "专辑" - -#: parsers/browsing.py:128 -msgid "singles" -msgstr "单曲" - -#: parsers/browsing.py:128 -msgid "videos" -msgstr "视频" - -#: parsers/browsing.py:128 -msgid "playlists" -msgstr "精选" - -#: parsers/browsing.py:128 -msgid "related" -msgstr "粉丝可能还会喜欢" From 40894b40b6350b177cb8f50a92b9c25c346a7f4c Mon Sep 17 00:00:00 2001 From: sigma67 <16363825+sigma67@users.noreply.github.com> Date: Sun, 12 Mar 2023 11:43:37 +0100 Subject: [PATCH 109/238] Create dependabot.yml --- .github/dependabot.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..91abb11f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" From 1ad3dc2312e464e709d82cea8b0ae89672012a72 Mon Sep 17 00:00:00 2001 From: sigma67 <16363825+sigma67@users.noreply.github.com> Date: Sun, 12 Mar 2023 12:11:29 +0100 Subject: [PATCH 110/238] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 23 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 22 ++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..92cc306f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,23 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +# Hello and thanks for using ytmusicapi! Please fill out the issue template below. Issues not following this template will be closed without comment. If you have a question only, please use GitHub discussions or gitter (linked in README). +~~~~~~~~~~~~~ +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Additional context** +Add any other context about the problem here, such as a code sample. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..e42ecf5b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,22 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +# Hello and thanks for using ytmusicapi! Please fill out the issue template below. Issues not following this template will be closed without comment. If you have a question only, please use GitHub discussions or gitter (linked in README). +~~~~~~~~~~~~~ +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From 3be40b1c6d5b1e8bb0b621a94a4a61c259854131 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 22 Mar 2023 21:15:08 +0100 Subject: [PATCH 111/238] setup: ignore sec headers --- ytmusicapi/setup.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ytmusicapi/setup.py b/ytmusicapi/setup.py index a8c0bcf3..0c76f612 100644 --- a/ytmusicapi/setup.py +++ b/ytmusicapi/setup.py @@ -39,8 +39,9 @@ def setup(filepath=None, headers_raw=None): ) ignore_headers = {"host", "content-length", "accept-encoding"} - for i in ignore_headers: - user_headers.pop(i, None) + for key in user_headers.copy().keys(): + if key.startswith("sec") or key in ignore_headers: + user_headers.pop(key, None) init_headers = initialize_headers() user_headers.update(init_headers) From 92d8e049aa47b81d87139d4e21017cfc3f852ec4 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 22 Mar 2023 21:29:23 +0100 Subject: [PATCH 112/238] update .pre-commit-config.yaml --- .pre-commit-config.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5ac36da2..b7e27caf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,10 @@ repos: - repo: https://github.com/pre-commit/mirrors-yapf - rev: v0.30.0 + rev: v0.32.0 hooks: - - id: yapf -- repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.0 + - id: yapf + additional_dependencies: [toml] +- repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 hooks: - id: flake8 From 5d7018bd977a0cce0844d4467a52070e88d308bf Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 22 Mar 2023 21:47:23 +0100 Subject: [PATCH 113/238] move .style.yapf to pyproject.toml --- .style.yapf | 5 ----- pyproject.toml | 4 ++++ 2 files changed, 4 insertions(+), 5 deletions(-) delete mode 100644 .style.yapf diff --git a/.style.yapf b/.style.yapf deleted file mode 100644 index a3a5528e..00000000 --- a/.style.yapf +++ /dev/null @@ -1,5 +0,0 @@ -[style] - -based_on_style = pep8 -column_limit = 99 -split_before_arithmetic_operator=true diff --git a/pyproject.toml b/pyproject.toml index e4e5ecf6..506e0286 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,3 +35,7 @@ include-package-data=false [tool.setuptools.package-data] "*" = ["**.rst", "**.py", "**.mo"] + +[tool.yapf] +column_limit = 99 +split_before_arithmetic_operator = true \ No newline at end of file From 19ad424e0e59b1908fcb767f8b85a83ecf5ecde2 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 22 Mar 2023 22:33:56 +0100 Subject: [PATCH 114/238] refactor search parsing to work with new top result (closes #360) --- tests/test.py | 2 + ytmusicapi/mixins/browsing.py | 10 +- ytmusicapi/mixins/explore.py | 2 + ytmusicapi/mixins/library.py | 2 + ytmusicapi/mixins/playlists.py | 31 ++-- ytmusicapi/mixins/search.py | 50 ++++--- ytmusicapi/navigation.py | 22 +-- ytmusicapi/parsers/browsing.py | 223 +++++----------------------- ytmusicapi/parsers/i18n.py | 45 ++++++ ytmusicapi/parsers/search.py | 195 ++++++++++++++++++++++++ ytmusicapi/parsers/search_params.py | 56 ------- ytmusicapi/ytmusic.py | 6 +- 12 files changed, 354 insertions(+), 290 deletions(-) create mode 100644 ytmusicapi/parsers/i18n.py create mode 100644 ytmusicapi/parsers/search.py delete mode 100644 ytmusicapi/parsers/search_params.py diff --git a/tests/test.py b/tests/test.py index 25b6398a..97191217 100644 --- a/tests/test.py +++ b/tests/test.py @@ -3,6 +3,7 @@ import configparser import time import sys + sys.path.insert(0, '..') from ytmusicapi.ytmusic import YTMusic # noqa: E402 @@ -15,6 +16,7 @@ class TestYTMusic(unittest.TestCase): + @classmethod def setUpClass(cls): cls.yt = YTMusic(requests_session=False) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 292c1702..9934f313 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -5,9 +5,11 @@ from ytmusicapi.parsers.albums import parse_album_header from ytmusicapi.parsers.playlists import parse_playlist_items from ytmusicapi.parsers.library import parse_albums +from typing import List, Dict class BrowsingMixin: + def get_home(self, limit=3) -> List[Dict]: """ Get the home page. @@ -99,14 +101,14 @@ def get_home(self, limit=3) -> List[Dict]: response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) home = [] - home.extend(self.parser.parse_mixed_content(results)) + home.extend(parse_mixed_content(results)) section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) if 'continuations' in section_list: request_func = lambda additionalParams: self._send_request( endpoint, body, additionalParams) - parse_func = lambda contents: self.parser.parse_mixed_content(contents) + parse_func = lambda contents: parse_mixed_content(contents) home.extend( get_continuations(section_list, 'sectionListContinuation', limit - len(home), @@ -329,7 +331,7 @@ def get_user_playlists(self, channelId: str, params: str) -> List[Dict]: return user_playlists - def get_album_browse_id(self, audioPlaylistId: str): + def get_album_browse_id(self, audioPlaylistId: str) -> str: """ Get an album's browseId based on its audioPlaylistId @@ -691,7 +693,7 @@ def get_song_related(self, browseId: str): response = self._send_request('browse', {'browseId': browseId}) sections = nav(response, ['contents'] + SECTION_LIST) - return self.parser.parse_mixed_content(sections) + return parse_mixed_content(sections) def get_lyrics(self, browseId: str) -> Dict: """ diff --git a/ytmusicapi/mixins/explore.py b/ytmusicapi/mixins/explore.py index d2bc1ed2..8690af73 100644 --- a/ytmusicapi/mixins/explore.py +++ b/ytmusicapi/mixins/explore.py @@ -1,7 +1,9 @@ from ytmusicapi.parsers.explore import * +from typing import List, Dict class ExploreMixin: + def get_mood_categories(self) -> Dict: """ Fetch "Moods & Genres" categories from YouTube Music. diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index eab1a8d4..71b01bba 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -3,9 +3,11 @@ from ._utils import * from ytmusicapi.parsers.browsing import * from ytmusicapi.parsers.library import * +from typing import List, Dict class LibraryMixin: + def get_library_playlists(self, limit: int = 25) -> List[Dict]: """ Retrieves the playlists in the user's library. diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 7cf12bad..477f3f5a 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -10,7 +10,12 @@ class PlaylistsMixin: - def get_playlist(self, playlistId: str, limit: int = 100, related: bool = False, suggestions_limit: int = 0) -> Dict: + + def get_playlist(self, + playlistId: str, + limit: int = 100, + related: bool = False, + suggestions_limit: int = 0) -> Dict: """ Returns a list of playlist items @@ -119,11 +124,11 @@ def get_playlist(self, playlistId: str, limit: int = 100, related: bool = False, playlist['title'] = nav(header, TITLE_TEXT) playlist['thumbnails'] = nav(header, THUMBNAIL_CROPPED) playlist["description"] = nav(header, DESCRIPTION, True) - run_count = len(header['subtitle']['runs']) + 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) + 'id': nav(header, SUBTITLE_RUNS + [2] + NAVIGATION_BROWSE_ID, True) } if run_count == 5: playlist['year'] = nav(header, SUBTITLE3) @@ -135,7 +140,8 @@ def get_playlist(self, playlistId: str, limit: int = 100, related: bool = False, playlist['trackCount'] = song_count - request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams + ) # suggestions and related are missing e.g. on liked songs section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) @@ -150,19 +156,20 @@ def get_playlist(self, playlistId: str, limit: int = 100, related: bool = False, 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)) + 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) parse_func = lambda results: parse_content_list(results, parse_playlist) - playlist['related'] = get_continuation_contents(nav(continuation, CONTENT + CAROUSEL), - parse_func) + playlist['related'] = get_continuation_contents( + nav(continuation, CONTENT + CAROUSEL), parse_func) if song_count > 0: playlist['tracks'] = parse_playlist_items(results['contents']) diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 281e61dc..2bba12cb 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -1,10 +1,10 @@ from typing import List, Dict -from ytmusicapi.navigation import * from ytmusicapi.continuations import get_continuations -from ytmusicapi.parsers.search_params import * +from ytmusicapi.parsers.search import * class SearchMixin: + def search(self, query: str, filter: str = None, @@ -141,8 +141,7 @@ def search(self, if scope == scopes[1] and filter: raise Exception( "No filter can be set when searching uploads. Please unset the filter parameter when scope is set to " - "uploads. " - ) + "uploads. ") params = get_search_params(filter, scope, ignore_spelling) if params: @@ -174,26 +173,39 @@ def search(self, filter = scopes[1] for res in results: - if 'musicShelfRenderer' in res: + if 'musicCardShelfRenderer' in res: + top_result = parse_top_result(res['musicCardShelfRenderer'], + self.parser.get_search_result_types()) + search_results.append(top_result) + if results := nav(res, ['musicCardShelfRenderer', 'contents'], True): + category = nav(results.pop(0), ['messageRenderer'] + TEXT_RUN_TEXT, True) + type = None + else: + continue + + elif 'musicShelfRenderer' in res: results = res['musicShelfRenderer']['contents'] - original_filter = filter + type_filter = filter category = nav(res, MUSIC_SHELF + TITLE_TEXT, True) - if not filter and scope == scopes[0]: - filter = category + if not type_filter and scope == scopes[0]: + type_filter = category + + type = type_filter[:-1].lower() if type_filter else None - type = filter[:-1].lower() if filter else None - search_results.extend(self.parser.parse_search_results(results, type, category)) - filter = original_filter + else: + continue - if 'continuations' in res['musicShelfRenderer']: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + search_results.extend( + parse_search_results(results, self.parser.get_search_result_types(), type, + category)) - parse_func = lambda contents: self.parser.parse_search_results( - contents, type, category) + if filter: # if filter is set, there are continuations + request_func = lambda additionalParams: self._send_request( + endpoint, body, additionalParams) + parse_func = lambda contents: parse_search_results(contents, type, category) - search_results.extend( - get_continuations(res['musicShelfRenderer'], 'musicShelfContinuation', - limit - len(search_results), request_func, parse_func)) + search_results.extend( + get_continuations(res['musicShelfRenderer'], 'musicShelfContinuation', + limit - len(search_results), request_func, parse_func)) return search_results diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 25ad1d31..11f2dde1 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -31,22 +31,15 @@ 'watchEndpoint', 'watchEndpointMusicSupportedConfigs', 'watchEndpointMusicConfig', 'musicVideoType' ] -HEADER_DETAIL = ['header', 'musicDetailHeaderRenderer'] -DESCRIPTION_SHELF = ['musicDescriptionShelfRenderer'] -DESCRIPTION = ['description'] + RUN_TEXT -CAROUSEL = ['musicCarouselShelfRenderer'] -IMMERSIVE_CAROUSEL = ['musicImmersiveCarouselShelfRenderer'] -CAROUSEL_CONTENTS = CAROUSEL + ['contents'] -CAROUSEL_TITLE = ['header', 'musicCarouselShelfBasicHeaderRenderer', 'title', 'runs', 0] -FRAMEWORK_MUTATIONS = ['frameworkUpdates', 'entityBatchUpdate', 'mutations'] TITLE = ['title', 'runs', 0] TITLE_TEXT = ['title'] + RUN_TEXT TEXT_RUNS = ['text', 'runs'] TEXT_RUN = TEXT_RUNS + [0] TEXT_RUN_TEXT = TEXT_RUN + ['text'] SUBTITLE = ['subtitle'] + RUN_TEXT -SUBTITLE2 = ['subtitle', 'runs', 2, 'text'] -SUBTITLE3 = ['subtitle', 'runs', 4, 'text'] +SUBTITLE_RUNS = ['subtitle', 'runs'] +SUBTITLE2 = SUBTITLE_RUNS + [2, 'text'] +SUBTITLE3 = SUBTITLE_RUNS + [4, 'text'] THUMBNAIL = ['thumbnail', 'thumbnails'] THUMBNAILS = ['thumbnail', 'musicThumbnailRenderer'] + THUMBNAIL THUMBNAIL_RENDERER = ['thumbnailRenderer', 'musicThumbnailRenderer'] + THUMBNAIL @@ -63,6 +56,15 @@ TASTE_PROFILE_ARTIST = ["title", "runs"] SECTION_LIST_CONTINUATION = ['continuationContents', 'sectionListContinuation'] MENU_PLAYLIST_ID = MENU_ITEMS + [0, 'menuNavigationItemRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID +HEADER_DETAIL = ['header', 'musicDetailHeaderRenderer'] +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 +FRAMEWORK_MUTATIONS = ['frameworkUpdates', 'entityBatchUpdate', 'mutations'] def nav(root, items, none_if_absent=False): diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 49d4d302..c221cd9a 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -1,195 +1,44 @@ -from typing import List, Dict from .songs import * from ._utils import * -class Parser: - def __init__(self, language): - self.lang = language - - def parse_mixed_content(self, rows): - items = [] - for row in rows: - if DESCRIPTION_SHELF[0] in row: - results = nav(row, DESCRIPTION_SHELF) - title = nav(results, ['header'] + RUN_TEXT) - contents = nav(results, DESCRIPTION) - else: - results = next(iter(row.values())) - if 'contents' not in results: - continue - title = nav(results, CAROUSEL_TITLE + ['text']) - contents = [] - for result in results['contents']: - data = nav(result, [MTRIR], True) - content = None - if data: - page_type = nav(data, TITLE + NAVIGATION_BROWSE + PAGE_TYPE, True) - if page_type is None: # song or watch_playlist - if nav(data, NAVIGATION_WATCH_PLAYLIST_ID, True) is not None: - content = parse_watch_playlist(data) - else: - content = parse_song(data) - elif page_type == "MUSIC_PAGE_TYPE_ALBUM": - content = parse_album(data) - elif page_type == "MUSIC_PAGE_TYPE_ARTIST": - content = parse_related_artist(data) - elif page_type == "MUSIC_PAGE_TYPE_PLAYLIST": - content = parse_playlist(data) - else: - data = nav(result, [MRLIR]) - content = parse_song_flat(data) - - contents.append(content) - - items.append({'title': title, 'contents': contents}) - return items - - @i18n - def parse_search_results(self, results, resultType=None, category=None): - search_results = [] - default_offset = (not resultType) * 2 - for result in results: - data = result[MRLIR] - search_result = {'category': category} - if not resultType: - resultType = get_item_text(data, 1).lower() - result_types = ['artist', 'playlist', 'song', 'video', 'station'] - result_types_local = [ - _('artist'), _('playlist'), - _('song'), _('video'), - _('station') - ] - # default to album since it's labeled with multiple values ('Single', 'EP', etc.) - if resultType not in result_types_local: - resultType = 'album' +def parse_mixed_content(rows): + items = [] + for row in rows: + if DESCRIPTION_SHELF[0] in row: + results = nav(row, DESCRIPTION_SHELF) + title = nav(results, ['header'] + RUN_TEXT) + contents = nav(results, DESCRIPTION) + else: + results = next(iter(row.values())) + if 'contents' not in results: + continue + title = nav(results, CAROUSEL_TITLE + ['text']) + contents = [] + for result in results['contents']: + data = nav(result, [MTRIR], True) + content = None + if data: + page_type = nav(data, TITLE + NAVIGATION_BROWSE + PAGE_TYPE, True) + if page_type is None: # song or watch_playlist + if nav(data, NAVIGATION_WATCH_PLAYLIST_ID, True) is not None: + content = parse_watch_playlist(data) + else: + content = parse_song(data) + elif page_type == "MUSIC_PAGE_TYPE_ALBUM": + content = parse_album(data) + elif page_type == "MUSIC_PAGE_TYPE_ARTIST": + content = parse_related_artist(data) + elif page_type == "MUSIC_PAGE_TYPE_PLAYLIST": + content = parse_playlist(data) else: - resultType = result_types[result_types_local.index(resultType)] - - search_result['resultType'] = resultType - - if resultType != 'artist': - search_result['title'] = get_item_text(data, 0) - - if resultType == 'artist': - search_result['artist'] = get_item_text(data, 0) - parse_menu_playlists(data, search_result) - - elif resultType == 'album': - search_result['type'] = get_item_text(data, 1) - - elif resultType == 'playlist': - flex_item = get_flex_column_item(data, 1)['text']['runs'] - has_author = len(flex_item) == default_offset + 3 - search_result['itemCount'] = nav( - flex_item, [default_offset + has_author * 2, 'text']).split(' ')[0] - search_result['author'] = None if not has_author else nav( - flex_item, [default_offset, 'text']) - - elif resultType == 'station': - search_result['videoId'] = nav(data, NAVIGATION_VIDEO_ID) - search_result['playlistId'] = nav(data, NAVIGATION_PLAYLIST_ID) - - elif resultType == 'song': - search_result['album'] = None - if 'menu' in data: - toggle_menu = find_object_by_key(nav(data, MENU_ITEMS), TOGGLE_MENU) - if toggle_menu: - search_result['feedbackTokens'] = parse_song_menu_tokens(toggle_menu) - - elif resultType == 'video': - search_result['views'] = None - search_result['videoType'] = nav(data, PLAY_BUTTON + NAVIGATION_VIDEO_TYPE, True) - - elif resultType == 'upload': - browse_id = nav(data, NAVIGATION_BROWSE_ID, True) - if not browse_id: # song result - flex_items = [ - nav(get_flex_column_item(data, i), ['text', 'runs'], True) - for i in range(2) - ] - if flex_items[0]: - search_result['videoId'] = nav(flex_items[0][0], NAVIGATION_VIDEO_ID, True) - search_result['playlistId'] = nav(flex_items[0][0], NAVIGATION_PLAYLIST_ID, - True) - if flex_items[1]: - search_result.update(parse_song_runs(flex_items[1])) - 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 = get_flex_column_item(data, 1) - runs = [ - run['text'] for i, run in enumerate(flex_item2['text']['runs']) - if i % 2 == 0 - ] - if len(runs) > 1: - search_result['artist'] = runs[1] - if len(runs) > 2: # date may be missing - search_result['releaseDate'] = runs[2] - search_result['resultType'] = 'album' - - if resultType in ['song', 'video']: - search_result['videoId'] = nav( - data, PLAY_BUTTON + ['playNavigationEndpoint', 'watchEndpoint', 'videoId'], - True) - search_result['videoType'] = nav( - data, PLAY_BUTTON + ['playNavigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) - - if resultType in ['song', 'video', 'album']: - search_result['duration'] = None - search_result['year'] = None - has_offset = resultType == 'album' or (default_offset - and bool(search_result['videoId'])) - flex_item = get_flex_column_item(data, 1) - runs = flex_item['text']['runs'][2 * has_offset:] - song_info = parse_song_runs(runs) - search_result.update(song_info) - - if resultType in ['artist', 'album', 'playlist']: - search_result['browseId'] = nav(data, NAVIGATION_BROWSE_ID, True) - if not search_result['browseId']: - continue - - if resultType in ['song', 'album']: - search_result['isExplicit'] = nav(data, BADGE_LABEL, True) is not None - - search_result['thumbnails'] = nav(data, THUMBNAILS, True) - search_results.append(search_result) - - return search_results - - @i18n - def parse_artist_contents(self, results: List) -> Dict: - categories = ['albums', 'singles', 'videos', 'playlists', 'related'] - categories_local = [_('albums'), _('singles'), _('videos'), _('playlists'), _('related')] - categories_parser = [ - parse_album, parse_single, parse_video, parse_playlist, parse_related_artist - ] - artist = {} - for i, category in enumerate(categories): - data = [ - r['musicCarouselShelfRenderer'] for r in results - if 'musicCarouselShelfRenderer' in r - and nav(r, CAROUSEL + CAROUSEL_TITLE)['text'].lower() == categories_local[i] - ] - if len(data) > 0: - artist[category] = {'browseId': None, 'results': []} - if 'navigationEndpoint' in nav(data[0], CAROUSEL_TITLE): - artist[category]['browseId'] = nav(data[0], - CAROUSEL_TITLE + NAVIGATION_BROWSE_ID) - if category in ['albums', 'singles', 'playlists']: - artist[category]['params'] = nav( - data[0], - CAROUSEL_TITLE)['navigationEndpoint']['browseEndpoint']['params'] + data = nav(result, [MRLIR]) + content = parse_song_flat(data) - artist[category]['results'] = parse_content_list(data[0]['contents'], - categories_parser[i]) + contents.append(content) - return artist + items.append({'title': title, 'contents': contents}) + return items def parse_content_list(results, parse_func, key=MTRIR): @@ -226,7 +75,7 @@ def parse_song(result): 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), 'thumbnails': nav(result, THUMBNAIL_RENDERER) } - song.update(parse_song_runs(result['subtitle']['runs'])) + song.update(parse_song_runs(nav(result, SUBTITLE_RUNS))) return song @@ -252,7 +101,7 @@ def parse_song_flat(data): def parse_video(result): - runs = result['subtitle']['runs'] + runs = nav(result, SUBTITLE_RUNS) artists_len = get_dot_separator_index(runs) return { 'title': nav(result, TITLE_TEXT), diff --git a/ytmusicapi/parsers/i18n.py b/ytmusicapi/parsers/i18n.py new file mode 100644 index 00000000..cfb94b7d --- /dev/null +++ b/ytmusicapi/parsers/i18n.py @@ -0,0 +1,45 @@ +from typing import List, Dict + +from ytmusicapi.navigation import nav, CAROUSEL, CAROUSEL_TITLE, NAVIGATION_BROWSE_ID +from ytmusicapi.parsers._utils import i18n +from ytmusicapi.parsers.browsing import parse_album, parse_single, parse_video, parse_playlist, parse_related_artist, \ + parse_content_list + + +class Parser: + + def __init__(self, language): + self.lang = language + + @i18n + def get_search_result_types(self): + return [_('artist'), _('playlist'), _('song'), _('video'), _('station')] + + @i18n + def parse_artist_contents(self, results: List) -> Dict: + categories = ['albums', 'singles', 'videos', 'playlists', 'related'] + categories_local = [_('albums'), _('singles'), _('videos'), _('playlists'), _('related')] + categories_parser = [ + parse_album, parse_single, parse_video, parse_playlist, parse_related_artist + ] + artist = {} + for i, category in enumerate(categories): + data = [ + r['musicCarouselShelfRenderer'] for r in results + if 'musicCarouselShelfRenderer' in r + and nav(r, CAROUSEL + CAROUSEL_TITLE)['text'].lower() == categories_local[i] + ] + if len(data) > 0: + artist[category] = {'browseId': None, 'results': []} + if 'navigationEndpoint' in nav(data[0], CAROUSEL_TITLE): + artist[category]['browseId'] = nav(data[0], + CAROUSEL_TITLE + NAVIGATION_BROWSE_ID) + if category in ['albums', 'singles', 'playlists']: + artist[category]['params'] = nav( + data[0], + CAROUSEL_TITLE)['navigationEndpoint']['browseEndpoint']['params'] + + artist[category]['results'] = parse_content_list(data[0]['contents'], + categories_parser[i]) + + return artist diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py new file mode 100644 index 00000000..43f30b45 --- /dev/null +++ b/ytmusicapi/parsers/search.py @@ -0,0 +1,195 @@ +from .songs import * +from ._utils import * + + +def get_search_result_type(result_type_local, result_types_local): + if not result_type_local: + return None + result_types = ['artist', 'playlist', 'song', 'video', 'station'] + result_type_local = result_type_local.lower() + # default to album since it's labeled with multiple values ('Single', 'EP', etc.) + if result_type_local not in result_types_local: + result_type = 'album' + else: + result_type = result_types[result_types_local.index(result_type_local)] + + return result_type + + +def parse_top_result(data, search_result_types): + search_result = {} + result_type = get_search_result_type(nav(data, SUBTITLE), search_result_types) + search_result['resultType'] = result_type + if result_type == 'artist': + subscribers = nav(data, SUBTITLE2, True) + if subscribers: + search_result['subscribers'] = subscribers.split(' ')[0] + + artist_info = parse_song_runs(nav(data, ['title', 'runs'])) + search_result.update(artist_info) + + if result_type in ['song', 'video', 'album']: + search_result['title'] = nav(data, TITLE_TEXT) + runs = nav(data, ['subtitle', 'runs'])[2:] + song_info = parse_song_runs(runs) + search_result.update(song_info) + + search_result['thumbnails'] = nav(data, THUMBNAILS, True) + return search_result + + +def parse_search_result(data, search_result_types, result_type, category): + default_offset = (not result_type) * 2 + search_result = {'category': category} + if video_type := nav(data, PLAY_BUTTON + ['playNavigationEndpoint'] + NAVIGATION_VIDEO_TYPE, + True): + result_type = 'song' if video_type == 'MUSIC_VIDEO_TYPE_ATV' else 'video' + + result_type = get_search_result_type(get_item_text(data, 1), + search_result_types) if not result_type else result_type + search_result['resultType'] = result_type + + if result_type != 'artist': + search_result['title'] = get_item_text(data, 0) + + if result_type == 'artist': + search_result['artist'] = get_item_text(data, 0) + parse_menu_playlists(data, search_result) + + elif result_type == 'album': + search_result['type'] = get_item_text(data, 1) + + elif result_type == 'playlist': + flex_item = get_flex_column_item(data, 1)['text']['runs'] + has_author = len(flex_item) == default_offset + 3 + search_result['itemCount'] = nav(flex_item, + [default_offset + has_author * 2, 'text']).split(' ')[0] + search_result['author'] = None if not has_author else nav(flex_item, + [default_offset, 'text']) + + elif result_type == 'station': + search_result['videoId'] = nav(data, NAVIGATION_VIDEO_ID) + search_result['playlistId'] = nav(data, NAVIGATION_PLAYLIST_ID) + + elif result_type == 'song': + search_result['album'] = None + if 'menu' in data: + toggle_menu = find_object_by_key(nav(data, MENU_ITEMS), TOGGLE_MENU) + if toggle_menu: + search_result['feedbackTokens'] = parse_song_menu_tokens(toggle_menu) + + elif result_type == 'upload': + browse_id = nav(data, NAVIGATION_BROWSE_ID, True) + if not browse_id: # song result + flex_items = [ + nav(get_flex_column_item(data, i), ['text', 'runs'], True) for i in range(2) + ] + if flex_items[0]: + search_result['videoId'] = nav(flex_items[0][0], NAVIGATION_VIDEO_ID, True) + search_result['playlistId'] = nav(flex_items[0][0], NAVIGATION_PLAYLIST_ID, True) + if flex_items[1]: + search_result.update(parse_song_runs(flex_items[1])) + 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 = get_flex_column_item(data, 1) + runs = [ + run['text'] for i, run in enumerate(flex_item2['text']['runs']) if i % 2 == 0 + ] + if len(runs) > 1: + search_result['artist'] = runs[1] + if len(runs) > 2: # date may be missing + search_result['releaseDate'] = runs[2] + search_result['resultType'] = 'album' + + if result_type in ['song', 'video']: + search_result['videoId'] = nav( + data, PLAY_BUTTON + ['playNavigationEndpoint', 'watchEndpoint', 'videoId'], True) + search_result['videoType'] = video_type + + if result_type in ['song', 'video', 'album']: + search_result['duration'] = None + search_result['year'] = None + flex_item = get_flex_column_item(data, 1) + runs = flex_item['text']['runs'][default_offset:] + song_info = parse_song_runs(runs) + search_result.update(song_info) + + if result_type in ['artist', 'album', 'playlist']: + search_result['browseId'] = nav(data, NAVIGATION_BROWSE_ID, True) + if not search_result['browseId']: + return {} + + if result_type in ['song', 'album']: + search_result['isExplicit'] = nav(data, BADGE_LABEL, True) is not None + + search_result['thumbnails'] = nav(data, THUMBNAILS, True) + + return search_result + + +def parse_search_results(results, search_result_types, resultType=None, category=None): + return [ + parse_search_result(result[MRLIR], search_result_types, resultType, category) + for result in results + ] + + +def get_search_params(filter, scope, ignore_spelling): + filtered_param1 = 'EgWKAQI' + params = None + if filter is None and scope is None and not ignore_spelling: + return params + + if scope == 'uploads': + params = 'agIYAw%3D%3D' + + if scope == 'library': + if filter: + param1 = filtered_param1 + param2 = _get_param2(filter) + param3 = 'AWoKEAUQCRADEAoYBA%3D%3D' + else: + params = 'agIYBA%3D%3D' + + if scope is None and filter: + if filter == 'playlists': + params = 'Eg-KAQwIABAAGAAgACgB' + if not ignore_spelling: + params += 'MABqChAEEAMQCRAFEAo%3D' + else: + params += 'MABCAggBagoQBBADEAkQBRAK' + + elif 'playlists' in filter: + param1 = 'EgeKAQQoA' + if filter == 'featured_playlists': + param2 = 'Dg' + else: # community_playlists + param2 = 'EA' + + if not ignore_spelling: + param3 = 'BagwQDhAKEAMQBBAJEAU%3D' + else: + param3 = 'BQgIIAWoMEA4QChADEAQQCRAF' + + else: + param1 = filtered_param1 + param2 = _get_param2(filter) + if not ignore_spelling: + param3 = 'AWoMEA4QChADEAQQCRAF' + else: + param3 = 'AUICCAFqDBAOEAoQAxAEEAkQBQ%3D%3D' + + if not scope and not filter and ignore_spelling: + params = 'EhGKAQ4IARABGAEgASgAOAFAAUICCAE%3D' + + return params if params else param1 + param2 + param3 + + +def _get_param2(filter): + filter_params = {'songs': 'I', 'videos': 'Q', 'albums': 'Y', 'artists': 'g', 'playlists': 'o'} + return filter_params[filter] diff --git a/ytmusicapi/parsers/search_params.py b/ytmusicapi/parsers/search_params.py deleted file mode 100644 index 0e41de5c..00000000 --- a/ytmusicapi/parsers/search_params.py +++ /dev/null @@ -1,56 +0,0 @@ -FILTERED_PARAM1 = 'EgWKAQI' - - -def get_search_params(filter, scope, ignore_spelling): - params = None - if filter is None and scope is None and not ignore_spelling: - return params - - if scope == 'uploads': - params = 'agIYAw%3D%3D' - - if scope == 'library': - if filter: - param1 = FILTERED_PARAM1 - param2 = _get_param2(filter) - param3 = 'AWoKEAUQCRADEAoYBA%3D%3D' - else: - params = 'agIYBA%3D%3D' - - if scope is None and filter: - if filter == 'playlists': - params = 'Eg-KAQwIABAAGAAgACgB' - if not ignore_spelling: - params += 'MABqChAEEAMQCRAFEAo%3D' - else: - params += 'MABCAggBagoQBBADEAkQBRAK' - - elif 'playlists' in filter: - param1 = 'EgeKAQQoA' - if filter == 'featured_playlists': - param2 = 'Dg' - else: # community_playlists - param2 = 'EA' - - if not ignore_spelling: - param3 = 'BagwQDhAKEAMQBBAJEAU%3D' - else: - param3 = 'BQgIIAWoMEA4QChADEAQQCRAF' - - else: - param1 = FILTERED_PARAM1 - param2 = _get_param2(filter) - if not ignore_spelling: - param3 = 'AWoMEA4QChADEAQQCRAF' - else: - param3 = 'AUICCAFqDBAOEAoQAxAEEAkQBQ%3D%3D' - - if not scope and not filter and ignore_spelling: - params = 'EhGKAQ4IARABGAEgASgAOAFAAUICCAE%3D' - - return params if params else param1 + param2 + param3 - - -def _get_param2(filter): - filter_params = {'songs': 'I', 'videos': 'Q', 'albums': 'Y', 'artists': 'g', 'playlists': 'o'} - return filter_params[filter] \ No newline at end of file diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index b1df4268..d4664632 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -5,8 +5,9 @@ from functools import partial from contextlib import suppress from typing import Dict + +from ytmusicapi.parsers.i18n import Parser from ytmusicapi.helpers import * -from ytmusicapi.parsers import browsing from ytmusicapi.setup import setup from ytmusicapi.mixins.browsing import BrowsingMixin from ytmusicapi.mixins.search import SearchMixin @@ -24,6 +25,7 @@ class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin Permits both authenticated and non-authenticated requests. Authentication header data must be provided on initialization. """ + def __init__(self, auth: str = None, user: str = None, @@ -113,7 +115,7 @@ def __init__(self, with suppress(locale.Error): locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') self.lang = gettext.translation('base', localedir=locale_dir, languages=[language]) - self.parser = browsing.Parser(self.lang) + self.parser = Parser(self.lang) if user: self.context['context']['user']['onBehalfOfUser'] = user From e14abac69ded1866f307fe146f7f375131c2c5f9 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 25 Mar 2023 10:13:51 +0100 Subject: [PATCH 115/238] get_playlist: add 'views' for owned playlists, fix trackCount (closes #365) --- tests/test.py | 10 +++++----- ytmusicapi/helpers.py | 6 +++++- ytmusicapi/mixins/playlists.py | 16 +++++++++------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/test.py b/tests/test.py index 97191217..49b17f81 100644 --- a/tests/test.py +++ b/tests/test.py @@ -372,23 +372,23 @@ def test_edit_playlist(self): # end to end test adding playlist, adding item, deleting item, deleting playlist def test_end2end(self): - playlistId = self.yt_auth.create_playlist( + playlistId = self.yt_brand.create_playlist( "test", "test description", source_playlist="OLAK5uy_lGQfnMNGvYCRdDq9ZLzJV2BJL2aHQsz9Y") self.assertEqual(len(playlistId), 34, "Playlist creation failed") - response = self.yt_auth.add_playlist_items( + response = self.yt_brand.add_playlist_items( playlistId, [sample_video, sample_video], source_playlist='OLAK5uy_nvjTE32aFYdFN7HCyMv3cGqD3wqBb4Jow', duplicates=True) self.assertEqual(response["status"], 'STATUS_SUCCEEDED', "Adding playlist item failed") self.assertGreater(len(response["playlistEditResults"]), 0, "Adding playlist item failed") time.sleep(2) - playlist = self.yt_auth.get_playlist(playlistId, related=True) + playlist = self.yt_brand.get_playlist(playlistId, related=True) self.assertEqual(len(playlist['tracks']), 46, "Getting playlist items failed") - response = self.yt_auth.remove_playlist_items(playlistId, playlist['tracks']) + response = self.yt_brand.remove_playlist_items(playlistId, playlist['tracks']) self.assertEqual(response, 'STATUS_SUCCEEDED', "Playlist item removal failed") - response = self.yt_auth.delete_playlist(playlistId) + response = self.yt_brand.delete_playlist(playlistId) self.assertEqual(response['command']['handlePlaylistDeletionCommand']['playlistId'], playlistId, "Playlist removal failed") diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index 10b60c1e..9432a807 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -4,6 +4,9 @@ from hashlib import sha1 import time import locale + +import unicodedata + from ytmusicapi.constants import * @@ -56,7 +59,8 @@ def get_authorization(auth): def to_int(string): - number_string = re.sub('[^\\d]', '', string) + string = unicodedata.normalize("NFKD", string) + number_string = re.sub(r'\D', '', string) try: int_value = locale.atoi(number_string) except ValueError: diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 477f3f5a..d668e093 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -1,4 +1,3 @@ -import unicodedata from typing import Dict, Union, Tuple from ._utils import * @@ -133,15 +132,18 @@ def get_playlist(self, if run_count == 5: playlist['year'] = nav(header, SUBTITLE3) - song_count = to_int( - unicodedata.normalize("NFKD", header['secondSubtitle']['runs'][0]['text'])) - if len(header['secondSubtitle']['runs']) > 1: - playlist['duration'] = header['secondSubtitle']['runs'][2]['text'] + second_subtitle_runs = header['secondSubtitle']['runs'] + own_offset = (own_playlist and len(second_subtitle_runs) > 3) * 2 + song_count = to_int(second_subtitle_runs[own_offset]['text']) + if len(second_subtitle_runs) > 1: + playlist['duration'] = second_subtitle_runs[own_offset + 2]['text'] playlist['trackCount'] = song_count + playlist['views'] = None + if own_playlist: + playlist['views'] = to_int(second_subtitle_runs[0]['text']) - request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams - ) + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) # suggestions and related are missing e.g. on liked songs section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) From 3a03c635fd0b7263e01146b66721648e3ddbb9d9 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 25 Mar 2023 10:32:56 +0100 Subject: [PATCH 116/238] improve search coverage --- tests/test.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test.py b/tests/test.py index 49b17f81..55be8175 100644 --- a/tests/test.py +++ b/tests/test.py @@ -50,8 +50,15 @@ def test_search(self): query = "edm playlist" self.assertRaises(Exception, self.yt_auth.search, query, filter="song") self.assertRaises(Exception, self.yt_auth.search, query, scope="upload") - results = self.yt.search(query) - self.assertGreater(len(results), 10) + results = self.yt.search("l1qwkfkah2l1qwkfkah2") + self.assertLessEqual(len(results), 2) + queries = ["taylor swift", "taylor swift blank space", "taylor swift fearless"] + for q in queries: + with self.subTest(): + results = self.yt_brand.search(q) + self.assertGreater(len(results), 10) + results = self.yt.search(q) + self.assertGreater(len(results), 10) results = self.yt_auth.search('Martin Stig Andersen - Deteriation', ignore_spelling=True) self.assertGreater(len(results), 0) results = self.yt_auth.search(query, filter='songs') From fcdb2ae7ba43532c181da1f71c645bffa7e9fc68 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 2 Apr 2023 11:35:40 +0200 Subject: [PATCH 117/238] search: fix bugs with top result (closes #367) --- tests/test.py | 2 +- ytmusicapi/mixins/search.py | 5 ++++- ytmusicapi/navigation.py | 3 ++- ytmusicapi/parsers/search.py | 9 +++++++-- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/test.py b/tests/test.py index 55be8175..df6ab335 100644 --- a/tests/test.py +++ b/tests/test.py @@ -50,7 +50,7 @@ def test_search(self): query = "edm playlist" self.assertRaises(Exception, self.yt_auth.search, query, filter="song") self.assertRaises(Exception, self.yt_auth.search, query, scope="upload") - results = self.yt.search("l1qwkfkah2l1qwkfkah2") + results = self.yt.search("動水切脱学医発料転探耳金処潟載") self.assertLessEqual(len(results), 2) queries = ["taylor swift", "taylor swift blank space", "taylor swift fearless"] for q in queries: diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 2bba12cb..9b734d2d 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -178,7 +178,10 @@ def search(self, self.parser.get_search_result_types()) search_results.append(top_result) if results := nav(res, ['musicCardShelfRenderer', 'contents'], True): - category = nav(results.pop(0), ['messageRenderer'] + TEXT_RUN_TEXT, True) + category = None + # category "more from youtube" is missing sometimes + if 'messageRenderer' in results[0]: + category = nav(results.pop(0), ['messageRenderer'] + TEXT_RUN_TEXT) type = None else: continue diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 11f2dde1..f8c105cc 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -24,7 +24,8 @@ PAGE_TYPE = [ 'browseEndpointContextSupportedConfigs', 'browseEndpointContextMusicConfig', 'pageType' ] -NAVIGATION_VIDEO_ID = ['navigationEndpoint', 'watchEndpoint', 'videoId'] +WATCH_VIDEO_ID = ['watchEndpoint', 'videoId'] +NAVIGATION_VIDEO_ID = ['navigationEndpoint'] + WATCH_VIDEO_ID NAVIGATION_PLAYLIST_ID = ['navigationEndpoint', 'watchEndpoint', 'playlistId'] NAVIGATION_WATCH_PLAYLIST_ID = ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'] NAVIGATION_VIDEO_TYPE = [ diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 43f30b45..bf38653f 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -17,9 +17,8 @@ def get_search_result_type(result_type_local, result_types_local): def parse_top_result(data, search_result_types): - search_result = {} result_type = get_search_result_type(nav(data, SUBTITLE), search_result_types) - search_result['resultType'] = result_type + search_result = {'category': nav(data, CARD_SHELF_TITLE), 'resultType': result_type} if result_type == 'artist': subscribers = nav(data, SUBTITLE2, True) if subscribers: @@ -28,6 +27,12 @@ def parse_top_result(data, search_result_types): artist_info = parse_song_runs(nav(data, ['title', 'runs'])) search_result.update(artist_info) + if result_type in ['song', 'video']: + on_tap = data.get('onTap') + if on_tap: + search_result['videoId'] = nav(on_tap, WATCH_VIDEO_ID) + search_result['videoType'] = nav(on_tap, NAVIGATION_VIDEO_TYPE) + if result_type in ['song', 'video', 'album']: search_result['title'] = nav(data, TITLE_TEXT) runs = nav(data, ['subtitle', 'runs'])[2:] From 6aa6c8d3822a39cd54027970cf0388209ca858e3 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 2 Apr 2023 18:48:15 +0200 Subject: [PATCH 118/238] fix tests --- tests/test.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test.py b/tests/test.py index df6ab335..9d813d99 100644 --- a/tests/test.py +++ b/tests/test.py @@ -50,8 +50,8 @@ def test_search(self): query = "edm playlist" self.assertRaises(Exception, self.yt_auth.search, query, filter="song") self.assertRaises(Exception, self.yt_auth.search, query, scope="upload") - results = self.yt.search("動水切脱学医発料転探耳金処潟載") - self.assertLessEqual(len(results), 2) + results = self.yt.search("۳™LJĺӣՊ֬ãʸύЩЂקդӀâ") + self.assertEqual(len(results), 0) queries = ["taylor swift", "taylor swift blank space", "taylor swift fearless"] for q in queries: with self.subTest(): @@ -118,7 +118,7 @@ def test_get_artist(self): self.assertGreaterEqual(len(results), 11) def test_get_artist_albums(self): - artist = self.yt.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") + artist = self.yt.get_artist("UCj5ZiBBqpe0Tg4zfKGHEFuQ") results = self.yt.get_artist_albums(artist['albums']['browseId'], artist['albums']['params']) self.assertGreater(len(results), 0) @@ -208,8 +208,8 @@ def test_get_mood_playlists(self): def test_get_charts(self): charts = self.yt_auth.get_charts() self.assertEqual(len(charts), 4) - charts = self.yt_auth.get_charts(country='US') - self.assertEqual(len(charts), 6) + charts = self.yt.get_charts(country='US') + self.assertEqual(len(charts), 5) charts = self.yt.get_charts(country='BE') self.assertEqual(len(charts), 4) From 28ae6b6c7aa8d3960b6f7b2f00dd41abc43f2a2c Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 5 Apr 2023 00:21:50 +0200 Subject: [PATCH 119/238] CI: run workflows on PRs --- .github/workflows/coverage.yml | 4 +--- .github/workflows/docsbuild.yml | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index e420dd03..784735fc 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,9 +1,7 @@ name: Code coverage on: - push: - branches: - - master + push jobs: build: diff --git a/.github/workflows/docsbuild.yml b/.github/workflows/docsbuild.yml index e9d4c87b..282c012c 100644 --- a/.github/workflows/docsbuild.yml +++ b/.github/workflows/docsbuild.yml @@ -1,9 +1,7 @@ name: Build Documentation on: - push: - branches: - - master + push jobs: build: From b0faaf9fee878af5320d1c94dcd5977fd117162b Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Wed, 5 Apr 2023 02:49:12 +0530 Subject: [PATCH 120/238] Implement get_search_suggestions method --- ytmusicapi/mixins/browsing.py | 95 ++++++++++++++++++++++++++++++++-- ytmusicapi/parsers/browsing.py | 22 ++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 9934f313..ff5e9b28 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -5,7 +5,7 @@ from ytmusicapi.parsers.albums import parse_album_header from ytmusicapi.parsers.playlists import parse_playlist_items from ytmusicapi.parsers.library import parse_albums -from typing import List, Dict +from typing import List, Dict, Union class BrowsingMixin: @@ -105,10 +105,12 @@ def get_home(self, limit=3) -> List[Dict]: section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) if 'continuations' in section_list: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) - parse_func = lambda contents: parse_mixed_content(contents) + def request_func(additionalParams): + return self._send_request(endpoint, body, additionalParams) + + def parse_func(contents): + return parse_mixed_content(contents) home.extend( get_continuations(section_list, 'sectionListContinuation', limit - len(home), @@ -812,3 +814,88 @@ def set_tasteprofile(self, artists: List[str], taste_profile: Dict = None) -> No body = {'browseId': "FEmusic_home", "formData": formData} self._send_request('browse', body) + + def get_search_suggestions(self, query: str, detailed_runs=False) -> Union[List, List[Dict]]: + """ + Get Search Suggestions + + :param query: Query string, i.e. 'faded' + :param detailed_runs: Whether to return detailed runs of each suggestion. + If True, it returns the query that the user typed and the remaining + suggestion along with the complete text (like many search services + usually bold the text typed by the user). + Default: False, returns the list of search suggestions in plain text. + :return: List of search suggestion results depending on detailed_runs param. + Examples: + 1. + Params: + query = 'fade' + detailed_runs = False + + Response: + [ + 'faded', + 'faded alan walker lyrics', + 'faded alan walker', + 'faded remix', + 'faded song', + 'faded lyrics', + 'faded instrumental' + ] + + 2. + Params: + query = 'fade' + detailed_runs = True + + Response: + Runs with bold: True show the text typed by user & other runs contain the remaining suggestion. + + [ + { + 'text': 'faded', + 'runs': [ + { + 'text': 'fade', + 'bold': True + }, + { + 'text': 'd' + } + ] + }, + { + 'text': 'faded alan walker lyrics', + 'runs': [ + { + 'text': 'fade', + 'bold': True + }, + { + 'text': 'd alan walker lyrics' + } + ] + }, + { + 'text': 'faded alan walker', + 'runs': [ + { + 'text': 'fade', + 'bold': True + }, + { + 'text': 'd alan walker' + } + ] + }, + ... + ] + """ + + body = {'input': query} + endpoint = 'music/get_search_suggestions' + + response = self._send_request(endpoint, body) + search_suggestions = parse_search_suggestions(response, detailed_runs) + + return search_suggestions diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index c221cd9a..308f859c 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -147,3 +147,25 @@ def parse_watch_playlist(data): 'playlistId': nav(data, NAVIGATION_WATCH_PLAYLIST_ID), 'thumbnails': nav(data, THUMBNAIL_RENDERER), } + + +def parse_search_suggestions(results, detailed_runs): + if not results.get('contents', [{}])[0].get('searchSuggestionsSectionRenderer', {}).get( + 'contents', []): + return [] + + raw_suggestions = results['contents'][0]['searchSuggestionsSectionRenderer']['contents'] + suggestions = [] + + for raw_suggestion in raw_suggestions: + suggestion_content = raw_suggestion['searchSuggestionRenderer'] + + text = suggestion_content['navigationEndpoint']['searchEndpoint']['query'] + runs = suggestion_content['suggestion']['runs'] + + if detailed_runs: + suggestions.append({'text': text, 'runs': runs}) + else: + suggestions.append(text) + + return suggestions From 39a10d6050fe384a2c62cfeda25d0741c385b54a Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Wed, 5 Apr 2023 02:56:33 +0530 Subject: [PATCH 121/238] Add unittest for get_search_suggestions --- tests/test.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test.py b/tests/test.py index 9d813d99..133d5eec 100644 --- a/tests/test.py +++ b/tests/test.py @@ -193,6 +193,13 @@ def test_get_tasteprofile(self): result = self.yt_auth.get_tasteprofile() self.assertGreaterEqual(len(result), 0) + def test_get_search_suggestions(self): + result = self.yt.get_search_suggestions("fade") + self.assertGreaterEqual(len(result), 0) + + result = self.yt.get_search_suggestions("fade", detailed_runs=True) + self.assertGreaterEqual(len(result), 0) + ################ # EXPLORE ################ From 4fdad5be04950a09a424358023bda9c2797f567d Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Wed, 5 Apr 2023 11:21:05 +0530 Subject: [PATCH 122/238] Move get_search_suggestions from browsing to search. --- ytmusicapi/mixins/browsing.py | 87 +----------------------------- ytmusicapi/mixins/search.py | 96 ++++++++++++++++++++++++++++++++-- ytmusicapi/parsers/browsing.py | 22 -------- ytmusicapi/parsers/search.py | 22 ++++++++ 4 files changed, 115 insertions(+), 112 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index ff5e9b28..e439e0bd 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -5,7 +5,7 @@ from ytmusicapi.parsers.albums import parse_album_header from ytmusicapi.parsers.playlists import parse_playlist_items from ytmusicapi.parsers.library import parse_albums -from typing import List, Dict, Union +from typing import List, Dict class BrowsingMixin: @@ -814,88 +814,3 @@ def set_tasteprofile(self, artists: List[str], taste_profile: Dict = None) -> No body = {'browseId': "FEmusic_home", "formData": formData} self._send_request('browse', body) - - def get_search_suggestions(self, query: str, detailed_runs=False) -> Union[List, List[Dict]]: - """ - Get Search Suggestions - - :param query: Query string, i.e. 'faded' - :param detailed_runs: Whether to return detailed runs of each suggestion. - If True, it returns the query that the user typed and the remaining - suggestion along with the complete text (like many search services - usually bold the text typed by the user). - Default: False, returns the list of search suggestions in plain text. - :return: List of search suggestion results depending on detailed_runs param. - Examples: - 1. - Params: - query = 'fade' - detailed_runs = False - - Response: - [ - 'faded', - 'faded alan walker lyrics', - 'faded alan walker', - 'faded remix', - 'faded song', - 'faded lyrics', - 'faded instrumental' - ] - - 2. - Params: - query = 'fade' - detailed_runs = True - - Response: - Runs with bold: True show the text typed by user & other runs contain the remaining suggestion. - - [ - { - 'text': 'faded', - 'runs': [ - { - 'text': 'fade', - 'bold': True - }, - { - 'text': 'd' - } - ] - }, - { - 'text': 'faded alan walker lyrics', - 'runs': [ - { - 'text': 'fade', - 'bold': True - }, - { - 'text': 'd alan walker lyrics' - } - ] - }, - { - 'text': 'faded alan walker', - 'runs': [ - { - 'text': 'fade', - 'bold': True - }, - { - 'text': 'd alan walker' - } - ] - }, - ... - ] - """ - - body = {'input': query} - endpoint = 'music/get_search_suggestions' - - response = self._send_request(endpoint, body) - search_suggestions = parse_search_suggestions(response, detailed_runs) - - return search_suggestions diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 9b734d2d..2d729693 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -1,4 +1,4 @@ -from typing import List, Dict +from typing import List, Dict, Union from ytmusicapi.continuations import get_continuations from ytmusicapi.parsers.search import * @@ -203,12 +203,100 @@ def search(self, category)) if filter: # if filter is set, there are continuations - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) - parse_func = lambda contents: parse_search_results(contents, type, category) + + def request_func(additionalParams): + return self._send_request(endpoint, body, additionalParams) + + def parse_func(contents): + return parse_search_results(contents, type, category) search_results.extend( get_continuations(res['musicShelfRenderer'], 'musicShelfContinuation', limit - len(search_results), request_func, parse_func)) return search_results + + def get_search_suggestions(self, query: str, detailed_runs=False) -> Union[List, List[Dict]]: + """ + Get Search Suggestions + + :param query: Query string, i.e. 'faded' + :param detailed_runs: Whether to return detailed runs of each suggestion. + If True, it returns the query that the user typed and the remaining + suggestion along with the complete text (like many search services + usually bold the text typed by the user). + Default: False, returns the list of search suggestions in plain text. + :return: List of search suggestion results depending on detailed_runs param. + Examples: + 1. + Params: + query = 'fade' + detailed_runs = False + + Response: + [ + 'faded', + 'faded alan walker lyrics', + 'faded alan walker', + 'faded remix', + 'faded song', + 'faded lyrics', + 'faded instrumental' + ] + + 2. + Params: + query = 'fade' + detailed_runs = True + + Response: + Runs with bold: True show the text typed by user & other runs contain the remaining suggestion. + + [ + { + 'text': 'faded', + 'runs': [ + { + 'text': 'fade', + 'bold': True + }, + { + 'text': 'd' + } + ] + }, + { + 'text': 'faded alan walker lyrics', + 'runs': [ + { + 'text': 'fade', + 'bold': True + }, + { + 'text': 'd alan walker lyrics' + } + ] + }, + { + 'text': 'faded alan walker', + 'runs': [ + { + 'text': 'fade', + 'bold': True + }, + { + 'text': 'd alan walker' + } + ] + }, + ... + ] + """ + + body = {'input': query} + endpoint = 'music/get_search_suggestions' + + response = self._send_request(endpoint, body) + search_suggestions = parse_search_suggestions(response, detailed_runs) + + return search_suggestions diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 308f859c..c221cd9a 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -147,25 +147,3 @@ def parse_watch_playlist(data): 'playlistId': nav(data, NAVIGATION_WATCH_PLAYLIST_ID), 'thumbnails': nav(data, THUMBNAIL_RENDERER), } - - -def parse_search_suggestions(results, detailed_runs): - if not results.get('contents', [{}])[0].get('searchSuggestionsSectionRenderer', {}).get( - 'contents', []): - return [] - - raw_suggestions = results['contents'][0]['searchSuggestionsSectionRenderer']['contents'] - suggestions = [] - - for raw_suggestion in raw_suggestions: - suggestion_content = raw_suggestion['searchSuggestionRenderer'] - - text = suggestion_content['navigationEndpoint']['searchEndpoint']['query'] - runs = suggestion_content['suggestion']['runs'] - - if detailed_runs: - suggestions.append({'text': text, 'runs': runs}) - else: - suggestions.append(text) - - return suggestions diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index bf38653f..b1a8d5f7 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -198,3 +198,25 @@ def get_search_params(filter, scope, ignore_spelling): def _get_param2(filter): filter_params = {'songs': 'I', 'videos': 'Q', 'albums': 'Y', 'artists': 'g', 'playlists': 'o'} return filter_params[filter] + + +def parse_search_suggestions(results, detailed_runs): + if not results.get('contents', [{}])[0].get('searchSuggestionsSectionRenderer', {}).get( + 'contents', []): + return [] + + raw_suggestions = results['contents'][0]['searchSuggestionsSectionRenderer']['contents'] + suggestions = [] + + for raw_suggestion in raw_suggestions: + suggestion_content = raw_suggestion['searchSuggestionRenderer'] + + text = suggestion_content['navigationEndpoint']['searchEndpoint']['query'] + runs = suggestion_content['suggestion']['runs'] + + if detailed_runs: + suggestions.append({'text': text, 'runs': runs}) + else: + suggestions.append(text) + + return suggestions From 7a4abd81a43dde2faed3c1085ba07f9787f41e17 Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Wed, 5 Apr 2023 11:22:23 +0530 Subject: [PATCH 123/238] Add get_search_suggestions reference and update readme. --- README.rst | 2 +- docs/source/reference.rst | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index e1d0f491..90d026b2 100644 --- a/README.rst +++ b/README.rst @@ -34,7 +34,7 @@ If you find something missing or broken, feel free to create an `issue ` page Search ------ .. automethod:: YTMusic.search +.. automethod:: YTMusic.get_search_suggestions Browsing -------- From 3d88e75ea5e3ae8253bbf6e0582f8e608c7e62b4 Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Wed, 5 Apr 2023 14:48:07 +0530 Subject: [PATCH 124/238] Fix documentation formatting for get_search_suggestions. --- ytmusicapi/mixins/search.py | 112 +++++++++++++++++------------------- 1 file changed, 52 insertions(+), 60 deletions(-) diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 2d729693..c5cbb354 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -216,7 +216,9 @@ def parse_func(contents): return search_results - def get_search_suggestions(self, query: str, detailed_runs=False) -> Union[List, List[Dict]]: + def get_search_suggestions(self, + query: str, + detailed_runs=False) -> Union[List[str], List[Dict]]: """ Get Search Suggestions @@ -226,71 +228,61 @@ def get_search_suggestions(self, query: str, detailed_runs=False) -> Union[List, suggestion along with the complete text (like many search services usually bold the text typed by the user). Default: False, returns the list of search suggestions in plain text. - :return: List of search suggestion results depending on detailed_runs param. - Examples: - 1. - Params: - query = 'fade' - detailed_runs = False - - Response: - [ - 'faded', - 'faded alan walker lyrics', - 'faded alan walker', - 'faded remix', - 'faded song', - 'faded lyrics', - 'faded instrumental' - ] - - 2. - Params: - query = 'fade' - detailed_runs = True - - Response: - Runs with bold: True show the text typed by user & other runs contain the remaining suggestion. - - [ + :return: List of search suggestion results depending on ``detailed_runs`` param. + + Example response when ``query`` is 'fade' and ``detailed_runs`` is set to ``False``:: + + [ + "faded", + "faded alan walker lyrics", + "faded alan walker", + "faded remix", + "faded song", + "faded lyrics", + "faded instrumental" + ] + + Example response when ``detailed_runs`` is set to ``True``:: + + [ + { + "text": "faded", + "runs": [ { - 'text': 'faded', - 'runs': [ - { - 'text': 'fade', - 'bold': True - }, - { - 'text': 'd' - } - ] + "text": "fade", + "bold": true }, { - 'text': 'faded alan walker lyrics', - 'runs': [ - { - 'text': 'fade', - 'bold': True - }, - { - 'text': 'd alan walker lyrics' - } - ] + "text": "d" + } + ] + }, + { + "text": "faded alan walker lyrics", + "runs": [ + { + "text": "fade", + "bold": true }, { - 'text': 'faded alan walker', - 'runs': [ - { - 'text': 'fade', - 'bold': True - }, - { - 'text': 'd alan walker' - } - ] + "text": "d alan walker lyrics" + } + ] + }, + { + "text": "faded alan walker", + "runs": [ + { + "text": "fade", + "bold": true }, - ... - ] + { + "text": "d alan walker" + } + ] + }, + ... + ] """ body = {'input': query} From 641424996981232cd77fcab698f599e1943b1764 Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Wed, 5 Apr 2023 16:03:18 +0530 Subject: [PATCH 125/238] Revert ytmusicapi/mixins/browsing.py file --- ytmusicapi/mixins/browsing.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index e439e0bd..9934f313 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -105,12 +105,10 @@ def get_home(self, limit=3) -> List[Dict]: section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) if 'continuations' in section_list: + request_func = lambda additionalParams: self._send_request( + endpoint, body, additionalParams) - def request_func(additionalParams): - return self._send_request(endpoint, body, additionalParams) - - def parse_func(contents): - return parse_mixed_content(contents) + parse_func = lambda contents: parse_mixed_content(contents) home.extend( get_continuations(section_list, 'sectionListContinuation', limit - len(home), From 0ee3b7fdb51e5735382c93349381be864d0a067c Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 5 Apr 2023 00:00:59 +0200 Subject: [PATCH 126/238] implement oauth authentication (#10) --- .github/workflows/coverage.yml | 9 ++-- .gitignore | 2 +- pyproject.toml | 5 +- tests/test.py | 69 ++++++++++++++++-------- ytmusicapi/__init__.py | 2 + ytmusicapi/auth/__init__.py | 0 ytmusicapi/auth/browser.py | 54 +++++++++++++++++++ ytmusicapi/auth/headers.py | 38 ++++++++++++++ ytmusicapi/auth/oauth.py | 71 +++++++++++++++++++++++++ ytmusicapi/constants.py | 9 +++- ytmusicapi/mixins/__init__.py | 0 ytmusicapi/parsers/__init__.py | 0 ytmusicapi/setup.py | 95 +++++++++++++++------------------- ytmusicapi/ytmusic.py | 49 ++++-------------- 14 files changed, 280 insertions(+), 123 deletions(-) create mode 100644 ytmusicapi/auth/__init__.py create mode 100644 ytmusicapi/auth/browser.py create mode 100644 ytmusicapi/auth/headers.py create mode 100644 ytmusicapi/auth/oauth.py create mode 100644 ytmusicapi/mixins/__init__.py create mode 100644 ytmusicapi/parsers/__init__.py diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 784735fc..bc292abb 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -19,11 +19,10 @@ jobs: run: | pip install -e . pip install coverage - cd tests - curl -o test.mp3 https://www.kozco.com/tech/piano2-CoolEdit.mp3 - cat <<< "$HEADERS_AUTH" > headers_auth.json - cat <<< "$TEST_CFG" > test.cfg - coverage run --source=../ytmusicapi -m unittest test.py + curl -o tests/test.mp3 https://www.kozco.com/tech/piano2-CoolEdit.mp3 + cat <<< "$HEADERS_AUTH" > tests/headers_auth.json + cat <<< "$TEST_CFG" > tests/test.cfg + coverage run coverage xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/.gitignore b/.gitignore index d8ecf8c8..a9adcb05 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ .idea .vscode -headers_auth.json +**.json docs/build **.cfg **.mp3 diff --git a/pyproject.toml b/pyproject.toml index 506e0286..fba5b9de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,4 +38,7 @@ include-package-data=false [tool.yapf] column_limit = 99 -split_before_arithmetic_operator = true \ No newline at end of file +split_before_arithmetic_operator = true + +[tool.coverage.run] +command_line = "-m unittest discover tests" diff --git a/tests/test.py b/tests/test.py index 133d5eec..375bf856 100644 --- a/tests/test.py +++ b/tests/test.py @@ -1,41 +1,66 @@ -import unittest -import unittest.mock +import json import configparser import time -import sys +import unittest +from pathlib import Path +from requests import Response +from unittest import mock -sys.path.insert(0, '..') +from ytmusicapi.setup import setup_oauth, setup # noqa: E402 from ytmusicapi.ytmusic import YTMusic # noqa: E402 + +def get_resource(file: str) -> str: + data_dir = Path(__file__).parent + return data_dir.joinpath(file).as_posix() + + config = configparser.RawConfigParser() -config.read('./test.cfg', 'utf-8') +config.read(get_resource('test.cfg'), 'utf-8') sample_album = "MPREb_4pL8gzRtw1p" # Eminem - Revival sample_video = "hpSrLjc5SMs" # Oasis - Wonderwall sample_playlist = "PL6bPxvf5dW5clc3y9wAoslzqUrmkZ5c-u" # very large playlist +headers_oauth = get_resource(config['auth']['headers_oauth']) +headers_browser = get_resource(config['auth']['headers_file']) + class TestYTMusic(unittest.TestCase): @classmethod def setUpClass(cls): - cls.yt = YTMusic(requests_session=False) - cls.yt_auth = YTMusic(config['auth']['headers_file']) + with YTMusic(requests_session=False) as yt: + pass + cls.yt = YTMusic() + cls.yt_oauth = YTMusic(headers_oauth) + cls.yt_auth = YTMusic(headers_browser) cls.yt_brand = YTMusic(config['auth']['headers'], config['auth']['brand_account']) cls.yt_empty = YTMusic(config['auth']['headers_empty'], config['auth']['brand_account_empty']) - def test_init(self): - self.assertRaises(Exception, YTMusic, "{}") - - def test_setup(self): - headers = YTMusic.setup(config['auth']['headers_file'], config['auth']['headers_raw']) + def test_setup_browser(self): + headers = setup(headers_browser, config['auth']['headers_raw']) self.assertGreaterEqual(len(headers), 2) headers_raw = config['auth']['headers_raw'].split('\n') - with unittest.mock.patch('builtins.input', side_effect=(headers_raw + [EOFError()])): - headers = YTMusic.setup(config['auth']['headers_file']) + with mock.patch('builtins.input', side_effect=(headers_raw + [EOFError()])): + headers = setup(headers_browser) self.assertGreaterEqual(len(headers), 2) + # @unittest.skip("Cannot test oauth flow non-interactively") + @mock.patch('requests.Response.json') + @mock.patch('requests.Session.post') + def test_setup_oauth(self, session_mock, json_mock): + session_mock.return_value = Response() + json_mock.side_effect = [ + json.loads(config['auth']['oauth_code']), + json.loads(config['auth']['oauth_token']) + ] + with mock.patch('builtins.input', return_value='y'): + headers = setup_oauth(headers_oauth) + self.assertEqual(len(headers), 6) + self.assertTrue(Path(headers_oauth).exists()) + ############### # BROWSING ############### @@ -50,8 +75,6 @@ def test_search(self): query = "edm playlist" self.assertRaises(Exception, self.yt_auth.search, query, filter="song") self.assertRaises(Exception, self.yt_auth.search, query, scope="upload") - results = self.yt.search("۳™LJĺӣՊ֬ãʸύЩЂקդӀâ") - self.assertEqual(len(results), 0) queries = ["taylor swift", "taylor swift blank space", "taylor swift fearless"] for q in queries: with self.subTest(): @@ -91,7 +114,7 @@ def test_search_uploads(self): self.assertGreater(len(results), 20) def test_search_library(self): - results = self.yt_auth.search('garrix', scope='library') + results = self.yt_oauth.search('garrix', scope='library') self.assertGreater(len(results), 5) results = self.yt_auth.search('bergersen', filter='songs', scope='library', limit=40) self.assertGreater(len(results), 10) @@ -244,7 +267,7 @@ def test_get_watch_playlist(self): ################ def test_get_library_playlists(self): - playlists = self.yt_auth.get_library_playlists(50) + playlists = self.yt_oauth.get_library_playlists(50) self.assertGreater(len(playlists), 25) playlists = self.yt_auth.get_library_playlists(None) @@ -255,7 +278,7 @@ def test_get_library_playlists(self): def test_get_library_songs(self): self.assertRaises(Exception, self.yt_auth.get_library_songs, None, True) - songs = self.yt_auth.get_library_songs(100) + songs = self.yt_oauth.get_library_songs(100) self.assertGreaterEqual(len(songs), 100) songs = self.yt_auth.get_library_songs(200, validate_responses=True) self.assertGreaterEqual(len(songs), config.getint('limits', 'library_songs')) @@ -265,7 +288,7 @@ def test_get_library_songs(self): self.assertEqual(len(songs), 0) def test_get_library_albums(self): - albums = self.yt_auth.get_library_albums(100) + albums = self.yt_oauth.get_library_albums(100) self.assertGreater(len(albums), 50) for album in albums: self.assertIn('playlistId', album) @@ -281,7 +304,7 @@ def test_get_library_albums(self): def test_get_library_artists(self): artists = self.yt_auth.get_library_artists(50) self.assertGreater(len(artists), 40) - artists = self.yt_brand.get_library_artists(order='a_to_z', limit=50) + artists = self.yt_oauth.get_library_artists(order='a_to_z', limit=50) self.assertGreater(len(artists), 40) artists = self.yt_brand.get_library_artists(limit=None) self.assertGreater(len(artists), config.getint('limits', 'library_artists')) @@ -305,7 +328,7 @@ def test_get_liked_songs(self): self.assertEqual(songs['trackCount'], 0) def test_get_history(self): - songs = self.yt_auth.get_history() + songs = self.yt_oauth.get_history() self.assertGreater(len(songs), 0) def test_manipulate_history_items(self): @@ -439,7 +462,7 @@ def test_get_library_upload_artists(self): def test_upload_song(self): self.assertRaises(Exception, self.yt_auth.upload_song, 'song.wav') - response = self.yt_auth.upload_song(config['uploads']['file']) + response = self.yt_auth.upload_song(get_resource(config['uploads']['file'])) self.assertEqual(response.status_code, 409) @unittest.skip("Do not delete uploads") diff --git a/ytmusicapi/__init__.py b/ytmusicapi/__init__.py index eb2ffeaf..6f505ef2 100644 --- a/ytmusicapi/__init__.py +++ b/ytmusicapi/__init__.py @@ -1,4 +1,5 @@ from ytmusicapi.ytmusic import YTMusic +from ytmusicapi.setup import setup, setup_oauth from importlib.metadata import version, PackageNotFoundError try: @@ -10,3 +11,4 @@ __copyright__ = 'Copyright 2022 sigma67' __license__ = 'MIT' __title__ = 'ytmusicapi' +__all__ = ["YTMusic", "setup_oauth", "setup"] diff --git a/ytmusicapi/auth/__init__.py b/ytmusicapi/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ytmusicapi/auth/browser.py b/ytmusicapi/auth/browser.py new file mode 100644 index 00000000..70e91f4a --- /dev/null +++ b/ytmusicapi/auth/browser.py @@ -0,0 +1,54 @@ +import os +import platform +from ytmusicapi.helpers import * + +path = os.path.dirname(os.path.realpath(__file__)) + os.sep + + +def setup_browser(filepath=None, headers_raw=None): + contents = [] + if not headers_raw: + eof = "Ctrl-D" if platform.system() != "Windows" else "'Enter, Ctrl-Z, Enter'" + print("Please paste the request headers from Firefox and press " + eof + " to continue:") + while True: + try: + line = input() + except EOFError: + break + contents.append(line) + else: + contents = headers_raw.split('\n') + + try: + user_headers = {} + for content in contents: + header = content.split(': ') + if len(header) == 1 or header[0].startswith( + ":"): # nothing was split or chromium headers + continue + user_headers[header[0].lower()] = ': '.join(header[1:]) + + except Exception as e: + raise Exception("Error parsing your input, please try again. Full error: " + str(e)) + + missing_headers = {"cookie", "x-goog-authuser"} - set(k.lower() for k in user_headers.keys()) + if missing_headers: + raise Exception( + "The following entries are missing in your headers: " + ", ".join(missing_headers) + + ". Please try a different request (such as /browse) and make sure you are logged in." + ) + + ignore_headers = {"host", "content-length", "accept-encoding"} + for key in user_headers.copy().keys(): + if key.startswith("sec") or key in ignore_headers: + user_headers.pop(key, None) + + init_headers = initialize_headers() + user_headers.update(init_headers) + headers = user_headers + + if filepath is not None: + with open(filepath, 'w') as file: + json.dump(headers, file, ensure_ascii=True, indent=4, sort_keys=True) + + return json.dumps(headers) diff --git a/ytmusicapi/auth/headers.py b/ytmusicapi/auth/headers.py new file mode 100644 index 00000000..39feff38 --- /dev/null +++ b/ytmusicapi/auth/headers.py @@ -0,0 +1,38 @@ +import json +import os +from typing import Optional, Dict + +import requests +from requests.structures import CaseInsensitiveDict + +from ytmusicapi.auth.oauth import YTMusicOAuth +from ytmusicapi.helpers import initialize_headers + + +def prepare_headers(session: requests.Session, + proxies: Optional[Dict] = None, + auth: Optional[str] = None) -> Dict: + headers = {} + if auth: + try: + if os.path.isfile(auth): + with open(auth) as json_file: + input_json = json.load(json_file) + else: + input_json = json.loads(auth) + + if "oauth.json" in auth: + oauth = YTMusicOAuth(session, proxies) + headers = oauth.load_headers(input_json, auth) + else: + headers = CaseInsensitiveDict(input_json) + + except Exception as e: + print( + "Failed loading provided credentials. Make sure to provide a string or a file path. " + "Reason: " + str(e)) + + else: # no authentication + headers = initialize_headers() + + return headers diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py new file mode 100644 index 00000000..b25c09fc --- /dev/null +++ b/ytmusicapi/auth/oauth.py @@ -0,0 +1,71 @@ +import time +from typing import Dict, Optional +import requests +import json + +from ytmusicapi.constants import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_SCOPE, OAUTH_CODE_URL, OAUTH_TOKEN_URL, OAUTH_USER_AGENT +from ytmusicapi.helpers import initialize_headers + + +class YTMusicOAuth: + """OAuth implementation for YouTube Music based on YouTube TV""" + + def __init__(self, session: requests.Session, proxies: Dict = None): + self._session = session + if proxies: + self._session.proxies.update(proxies) + + def _send_request(self, url, data) -> requests.Response: + data.update({"client_id": OAUTH_CLIENT_ID}) + headers = {"User-Agent": OAUTH_USER_AGENT} + return self._session.post(url, data, headers=headers) + + def get_code(self) -> Dict: + code_response = self._send_request(OAUTH_CODE_URL, data={"scope": OAUTH_SCOPE}) + response_json = code_response.json() + url = f"{response_json['verification_url']}?user_code={response_json['user_code']}" + input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") + return response_json + + def get_token_from_code(self, device_code: str) -> Dict: + token_response = self._send_request(OAUTH_TOKEN_URL, + data={ + "client_secret": OAUTH_CLIENT_SECRET, + "grant_type": + "http://oauth.net/grant_type/device/1.0", + "code": device_code + }) + return token_response.json() + + def refresh_token(self, refresh_token: str) -> Dict: + response = self._send_request(OAUTH_TOKEN_URL, + data={ + "client_secret": OAUTH_CLIENT_SECRET, + "grant_type": "refresh_token", + "refresh_token": refresh_token + }) + return response.json() + + @staticmethod + def dump_token(token, filepath): + token["expires_at"] = int(time.time()) + int(token["expires_in"]) + with open(filepath, encoding="utf8", mode="w") as file: + json.dump(token, file) + + def setup(self, filepath: Optional[str] = None) -> Dict: + code = self.get_code() + token = self.get_token_from_code(code["device_code"]) + if filepath: + self.dump_token(token, filepath) + + return token + + def load_headers(self, token: Dict, filepath: Optional[str] = None): + headers = initialize_headers() + if time.time() > token["expires_at"] - 3600: + token = self.refresh_token(token["refresh_token"]) + self.dump_token(token, filepath) + headers["Authorization"] = f"{token['token_type']} {token['access_token']}" + headers["Content-Type"] = "application/json" + headers["X-Goog-Request-Time"] = str(int(time.time())) + return headers diff --git a/ytmusicapi/constants.py b/ytmusicapi/constants.py index 6b6a8d48..f5cca615 100644 --- a/ytmusicapi/constants.py +++ b/ytmusicapi/constants.py @@ -1,4 +1,11 @@ YTM_DOMAIN = 'https://music.youtube.com' YTM_BASE_API = YTM_DOMAIN + '/youtubei/v1/' -YTM_PARAMS = '?alt=json&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30' +YTM_PARAMS = '?alt=json' +YTM_PARAMS_KEY = "&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0' +OAUTH_CLIENT_ID = "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com" +OAUTH_CLIENT_SECRET = "SboVhoG9s0rNafixCSGGKXAT" +OAUTH_SCOPE = "https://www.googleapis.com/auth/youtube" +OAUTH_CODE_URL = "https://www.youtube.com/o/oauth2/device/code" +OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token" +OAUTH_USER_AGENT = USER_AGENT + " Cobalt/Version" diff --git a/ytmusicapi/mixins/__init__.py b/ytmusicapi/mixins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ytmusicapi/parsers/__init__.py b/ytmusicapi/parsers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ytmusicapi/setup.py b/ytmusicapi/setup.py index 0c76f612..b6b9df62 100644 --- a/ytmusicapi/setup.py +++ b/ytmusicapi/setup.py @@ -1,54 +1,41 @@ -import os -import platform -from ytmusicapi.helpers import * - -path = os.path.dirname(os.path.realpath(__file__)) + os.sep - - -def setup(filepath=None, headers_raw=None): - contents = [] - if not headers_raw: - eof = "Ctrl-D" if platform.system() != "Windows" else "'Enter, Ctrl-Z, Enter'" - print("Please paste the request headers from Firefox and press " + eof + " to continue:") - while True: - try: - line = input() - except EOFError: - break - contents.append(line) - else: - contents = headers_raw.split('\n') - - try: - user_headers = {} - for content in contents: - header = content.split(': ') - if len(header) == 1 or header[0].startswith( - ":"): # nothing was split or chromium headers - continue - user_headers[header[0].lower()] = ': '.join(header[1:]) - - except Exception as e: - raise Exception("Error parsing your input, please try again. Full error: " + str(e)) - - missing_headers = {"cookie", "x-goog-authuser"} - set(k.lower() for k in user_headers.keys()) - if missing_headers: - raise Exception( - "The following entries are missing in your headers: " + ", ".join(missing_headers) - + ". Please try a different request (such as /browse) and make sure you are logged in." - ) - - ignore_headers = {"host", "content-length", "accept-encoding"} - for key in user_headers.copy().keys(): - if key.startswith("sec") or key in ignore_headers: - user_headers.pop(key, None) - - init_headers = initialize_headers() - user_headers.update(init_headers) - headers = user_headers - - if filepath is not None: - with open(filepath, 'w') as file: - json.dump(headers, file, ensure_ascii=True, indent=4, sort_keys=True) - - return json.dumps(headers) +from typing import Dict + +import requests + +from ytmusicapi.auth.browser import setup_browser +from ytmusicapi.auth.oauth import YTMusicOAuth + + +def setup(filepath: str = None, headers_raw: str = None) -> Dict: + """ + Requests browser headers from the user via command line + and returns a string that can be passed to YTMusic() + + :param filepath: Optional filepath to store headers to. + :param headers_raw: Optional request headers copied from browser. + Otherwise requested from terminal + :return: configuration headers string + """ + return setup_browser(filepath, headers_raw) + + +def setup_oauth(filepath: str = None, + session: requests.Session = None, + proxies: dict = None) -> Dict: + """ + Starts oauth flow from the terminal + and returns a string that can be passed to YTMusic() + + :param session: Session to use for authentication + :param proxies: Proxies to use for authentication + :param filepath: Optional filepath to store headers to. + :return: configuration headers string + """ + if not session: + session = requests.Session() + + return YTMusicOAuth(session, proxies).setup(filepath) + + +if __name__ == "__main__": + setup_oauth() diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index d4664632..8041a0b6 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -1,14 +1,13 @@ import requests import gettext import os -from requests.structures import CaseInsensitiveDict from functools import partial from contextlib import suppress from typing import Dict +from ytmusicapi.auth.headers import prepare_headers from ytmusicapi.parsers.i18n import Parser from ytmusicapi.helpers import * -from ytmusicapi.setup import setup from ytmusicapi.mixins.browsing import BrowsingMixin from ytmusicapi.mixins.search import SearchMixin from ytmusicapi.mixins.watch import WatchMixin @@ -37,7 +36,6 @@ def __init__(self, :param auth: Optional. Provide a string or path to file. Authentication credentials are needed to manage your library. - Should be an adjusted version of `headers_auth.json.example` in the project root. See :py:func:`setup` for how to fill in the correct credentials. Default: A default header is used without authentication. :param user: Optional. Specify a user ID string to use in requests. @@ -79,23 +77,7 @@ def __init__(self, self.proxies = proxies self.cookies = {'CONSENT': 'YES+1'} - # prepare headers - if auth: - try: - if os.path.isfile(auth): - file = auth - with open(file) as json_file: - self.headers = CaseInsensitiveDict(json.load(json_file)) - else: - self.headers = CaseInsensitiveDict(json.loads(auth)) - - except Exception as e: - print( - "Failed loading provided credentials. Make sure to provide a string or a file path. " - "Reason: " + str(e)) - - else: # no authentication - self.headers = initialize_headers() + self.headers = prepare_headers(self._session, proxies, auth) if 'x-goog-visitor-id' not in self.headers: self.headers.update(get_visitor_id(self._send_get_request)) @@ -120,8 +102,9 @@ def __init__(self, if user: self.context['context']['user']['onBehalfOfUser'] = user - # verify authentication credentials work - if auth: + auth_header = self.headers.get("authorization") + self.is_browser_auth = auth_header and "SAPISIDHASH" in auth_header + if self.is_browser_auth: try: cookie = self.headers.get('cookie') self.sapisid = sapisid_from_cookie(cookie) @@ -130,10 +113,13 @@ def __init__(self, def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") -> Dict: body.update(self.context) - if self.auth: + params = YTM_PARAMS + if self.is_browser_auth: origin = self.headers.get('origin', self.headers.get('x-origin')) - self.headers["Authorization"] = get_authorization(self.sapisid + ' ' + origin) - response = self._session.post(YTM_BASE_API + endpoint + YTM_PARAMS + additionalParams, + self.headers["authorization"] = get_authorization(self.sapisid + ' ' + origin) + params += YTM_PARAMS_KEY + + response = self._session.post(YTM_BASE_API + endpoint + params + additionalParams, json=body, headers=self.headers, proxies=self.proxies, @@ -158,19 +144,6 @@ def _check_auth(self): if not self.auth: raise Exception("Please provide authentication before using this function") - @classmethod - def setup(cls, filepath: str = None, headers_raw: str = None) -> Dict: - """ - Requests browser headers from the user via command line - and returns a string that can be passed to YTMusic() - - :param filepath: Optional filepath to store headers to. - :param headers_raw: Optional request headers copied from browser. - Otherwise requested from terminal - :return: configuration headers string - """ - return setup(filepath, headers_raw) - def __enter__(self): return self From 5526de04613df2c4b077a5ddaeade1808222a350 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 5 Apr 2023 00:35:08 +0200 Subject: [PATCH 127/238] CI: add OAUTH_JSON secret --- .github/workflows/coverage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index bc292abb..151a31a5 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -21,6 +21,7 @@ jobs: pip install coverage curl -o tests/test.mp3 https://www.kozco.com/tech/piano2-CoolEdit.mp3 cat <<< "$HEADERS_AUTH" > tests/headers_auth.json + cat <<< "$OAUTH_JSON" > tests/oauth.json cat <<< "$TEST_CFG" > tests/test.cfg coverage run coverage xml From 34ad3c4c305dad469af3c2ccddcc696e627c556e Mon Sep 17 00:00:00 2001 From: sigma67 <16363825+sigma67@users.noreply.github.com> Date: Wed, 5 Apr 2023 14:35:01 +0200 Subject: [PATCH 128/238] Update coverage.yml --- .github/workflows/coverage.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 151a31a5..ba17c192 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -12,6 +12,12 @@ jobs: uses: actions/setup-python@master with: python-version: 3.x + - name: create-json + uses: jsdaniell/create-json@1.2.2 + with: + name: "oauth.json" + dir: "tests/" + json: ${{ secrets.OAUTH_JSON }} - name: Generate coverage report env: HEADERS_AUTH: ${{ secrets.HEADERS_AUTH }} @@ -21,7 +27,6 @@ jobs: pip install coverage curl -o tests/test.mp3 https://www.kozco.com/tech/piano2-CoolEdit.mp3 cat <<< "$HEADERS_AUTH" > tests/headers_auth.json - cat <<< "$OAUTH_JSON" > tests/oauth.json cat <<< "$TEST_CFG" > tests/test.cfg coverage run coverage xml From 308eda0dbba86af9ec097c6cdc3655318ab15e16 Mon Sep 17 00:00:00 2001 From: sigma67 <16363825+sigma67@users.noreply.github.com> Date: Wed, 5 Apr 2023 14:38:53 +0200 Subject: [PATCH 129/238] Update coverage.yml --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index ba17c192..698e77ad 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -13,7 +13,7 @@ jobs: with: python-version: 3.x - name: create-json - uses: jsdaniell/create-json@1.2.2 + uses: jsdaniell/create-json@v1.2.2 with: name: "oauth.json" dir: "tests/" From 0cb14450d46d98124bb61ab6cbe8ef2f07ad3c2b Mon Sep 17 00:00:00 2001 From: sigma67 <16363825+sigma67@users.noreply.github.com> Date: Wed, 5 Apr 2023 14:43:57 +0200 Subject: [PATCH 130/238] Update coverage.yml --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 698e77ad..2e32cf7c 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -33,6 +33,6 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: - file: tests/coverage.xml + file: coverage.xml flags: unittests fail_ci_if_error: true From f56bef27cd0dc4bccadbcc8ae4504690a4c08a6a Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 5 Apr 2023 21:24:54 +0200 Subject: [PATCH 131/238] update pythonpublish.yml --- .github/workflows/pythonpublish.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 36855e19..5cfd4e9c 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -24,8 +24,7 @@ jobs: pip install build - name: Build package run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 with: - user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} From 1a78e615da7febe0a12b6d0c0bbb542c222f8c51 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 5 Apr 2023 21:25:23 +0200 Subject: [PATCH 132/238] streamline oauth code and fix refresh_token bug --- ytmusicapi/auth/headers.py | 28 +++++++++++----------------- ytmusicapi/auth/oauth.py | 4 ++-- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/ytmusicapi/auth/headers.py b/ytmusicapi/auth/headers.py index 39feff38..5a4a2faa 100644 --- a/ytmusicapi/auth/headers.py +++ b/ytmusicapi/auth/headers.py @@ -14,23 +14,17 @@ def prepare_headers(session: requests.Session, auth: Optional[str] = None) -> Dict: headers = {} if auth: - try: - if os.path.isfile(auth): - with open(auth) as json_file: - input_json = json.load(json_file) - else: - input_json = json.loads(auth) - - if "oauth.json" in auth: - oauth = YTMusicOAuth(session, proxies) - headers = oauth.load_headers(input_json, auth) - else: - headers = CaseInsensitiveDict(input_json) - - except Exception as e: - print( - "Failed loading provided credentials. Make sure to provide a string or a file path. " - "Reason: " + str(e)) + if os.path.isfile(auth): + with open(auth) as json_file: + input_json = json.load(json_file) + else: + input_json = json.loads(auth) + + if "oauth.json" in auth: + oauth = YTMusicOAuth(session, proxies) + headers = oauth.load_headers(input_json, auth) + else: + headers = CaseInsensitiveDict(input_json) else: # no authentication headers = initialize_headers() diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index b25c09fc..c866a130 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -50,7 +50,7 @@ def refresh_token(self, refresh_token: str) -> Dict: def dump_token(token, filepath): token["expires_at"] = int(time.time()) + int(token["expires_in"]) with open(filepath, encoding="utf8", mode="w") as file: - json.dump(token, file) + json.dump(token, file, indent=True) def setup(self, filepath: Optional[str] = None) -> Dict: code = self.get_code() @@ -63,7 +63,7 @@ def setup(self, filepath: Optional[str] = None) -> Dict: def load_headers(self, token: Dict, filepath: Optional[str] = None): headers = initialize_headers() if time.time() > token["expires_at"] - 3600: - token = self.refresh_token(token["refresh_token"]) + token.update(self.refresh_token(token["refresh_token"])) self.dump_token(token, filepath) headers["Authorization"] = f"{token['token_type']} {token['access_token']}" headers["Content-Type"] = "application/json" From d111be24e04cf5f51167d9caf1d1e00709bc963e Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 5 Apr 2023 21:25:38 +0200 Subject: [PATCH 133/238] use oauth for more tests --- tests/test.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/test.py b/tests/test.py index 375bf856..ec726bd1 100644 --- a/tests/test.py +++ b/tests/test.py @@ -176,14 +176,14 @@ def test_get_album(self): self.assertEqual(len(results['tracks']), 7) def test_get_song(self): - song = self.yt_auth.get_song(config['uploads']['private_upload_id']) # private upload + song = self.yt_oauth.get_song(config['uploads']['private_upload_id']) # private upload self.assertEqual(len(song), 5) song = self.yt.get_song(sample_video) self.assertGreaterEqual(len(song['streamingData']['adaptiveFormats']), 10) def test_get_song_related_content(self): - song = self.yt_auth.get_watch_playlist(sample_video) - song = self.yt_auth.get_song_related(song["related"]) + song = self.yt_oauth.get_watch_playlist(sample_video) + song = self.yt_oauth.get_song_related(song["related"]) self.assertGreaterEqual(len(song), 5) def test_get_lyrics(self): @@ -213,7 +213,7 @@ def test_get_tasteprofile(self): result = self.yt.get_tasteprofile() self.assertGreaterEqual(len(result), 0) - result = self.yt_auth.get_tasteprofile() + result = self.yt_oauth.get_tasteprofile() self.assertGreaterEqual(len(result), 0) def test_get_search_suggestions(self): @@ -236,7 +236,7 @@ def test_get_mood_playlists(self): self.assertGreater(len(playlists), 0) def test_get_charts(self): - charts = self.yt_auth.get_charts() + charts = self.yt_oauth.get_charts() self.assertEqual(len(charts), 4) charts = self.yt.get_charts(country='US') self.assertEqual(len(charts), 5) @@ -248,12 +248,12 @@ def test_get_charts(self): ############### def test_get_watch_playlist(self): - playlist = self.yt_auth.get_watch_playlist( + playlist = self.yt_oauth.get_watch_playlist( playlistId="RDAMPLOLAK5uy_l_fKDQGOUsk8kbWsm9s86n4-nZNd2JR8Q", radio=True, limit=90) self.assertGreaterEqual(len(playlist['tracks']), 90) - playlist = self.yt_auth.get_watch_playlist("9mWr4c_ig54", limit=50) + playlist = self.yt_oauth.get_watch_playlist("9mWr4c_ig54", limit=50) self.assertGreater(len(playlist['tracks']), 45) - playlist = self.yt_auth.get_watch_playlist("UoAf_y9Ok4k") # private track + playlist = self.yt_oauth.get_watch_playlist("UoAf_y9Ok4k") # private track self.assertGreaterEqual(len(playlist['tracks']), 25) playlist = self.yt.get_watch_playlist( playlistId="OLAK5uy_lKgoGvlrWhX0EIPavQUXxyPed8Cj38AWc", shuffle=True) @@ -434,14 +434,14 @@ def test_end2end(self): ############### def test_get_library_upload_songs(self): - results = self.yt_auth.get_library_upload_songs(50, order='z_to_a') + results = self.yt_oauth.get_library_upload_songs(50, order='z_to_a') self.assertGreater(len(results), 25) results = self.yt_empty.get_library_upload_songs(100) self.assertEqual(len(results), 0) def test_get_library_upload_albums(self): - results = self.yt_auth.get_library_upload_albums(50, order='a_to_z') + results = self.yt_oauth.get_library_upload_albums(50, order='a_to_z') self.assertGreater(len(results), 40) albums = self.yt_auth.get_library_upload_albums(None) @@ -451,7 +451,7 @@ def test_get_library_upload_albums(self): self.assertEqual(len(results), 0) def test_get_library_upload_artists(self): - artists = self.yt_auth.get_library_upload_artists(None) + artists = self.yt_oauth.get_library_upload_artists(None) self.assertGreaterEqual(len(artists), config.getint('limits', 'library_upload_artists')) results = self.yt_auth.get_library_upload_artists(50, order='recently_added') @@ -467,16 +467,16 @@ def test_upload_song(self): @unittest.skip("Do not delete uploads") def test_delete_upload_entity(self): - results = self.yt_auth.get_library_upload_songs() - response = self.yt_auth.delete_upload_entity(results[0]['entityId']) + results = self.yt_oauth.get_library_upload_songs() + response = self.yt_oauth.delete_upload_entity(results[0]['entityId']) self.assertEqual(response, 'STATUS_SUCCEEDED') def test_get_library_upload_album(self): - album = self.yt_auth.get_library_upload_album(config['uploads']['private_album_id']) + album = self.yt_oauth.get_library_upload_album(config['uploads']['private_album_id']) self.assertGreater(len(album['tracks']), 0) def test_get_library_upload_artist(self): - tracks = self.yt_auth.get_library_upload_artist(config['uploads']['private_artist_id'], + tracks = self.yt_oauth.get_library_upload_artist(config['uploads']['private_artist_id'], 100) self.assertGreater(len(tracks), 0) From 012a0fc85d52c82bb9ae932acf275c3607cc564f Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 5 Apr 2023 23:23:05 +0200 Subject: [PATCH 134/238] add cli --- pyproject.toml | 3 +++ tests/test.py | 13 +++++++------ ytmusicapi/__init__.py | 2 +- ytmusicapi/mixins/browsing.py | 4 ++-- ytmusicapi/setup.py | 21 +++++++++++++++++++-- 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fba5b9de..cb28854e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,9 @@ dynamic = ["version", "readme"] [project.optional-dependencies] dev = ['pre-commit', 'flake8', 'yapf', 'coverage', 'sphinx', 'sphinx-rtd-theme'] +[project.scripts] +ytmusicapi = "ytmusicapi.setup:main" + [project.urls] homepage = "https://github.com/sigma67/ytmusicapi" documentation = "https://ytmusicapi.readthedocs.io" diff --git a/tests/test.py b/tests/test.py index ec726bd1..b5a60ae8 100644 --- a/tests/test.py +++ b/tests/test.py @@ -6,7 +6,7 @@ from requests import Response from unittest import mock -from ytmusicapi.setup import setup_oauth, setup # noqa: E402 +from ytmusicapi.setup import main, setup # noqa: E402 from ytmusicapi.ytmusic import YTMusic # noqa: E402 @@ -31,7 +31,7 @@ class TestYTMusic(unittest.TestCase): @classmethod def setUpClass(cls): with YTMusic(requests_session=False) as yt: - pass + assert isinstance(yt, YTMusic) cls.yt = YTMusic() cls.yt_oauth = YTMusic(headers_oauth) cls.yt_auth = YTMusic(headers_browser) @@ -39,17 +39,19 @@ def setUpClass(cls): cls.yt_empty = YTMusic(config['auth']['headers_empty'], config['auth']['brand_account_empty']) + @mock.patch('sys.argv', ["ytmusicapi", "browser", "--file", headers_browser]) def test_setup_browser(self): headers = setup(headers_browser, config['auth']['headers_raw']) self.assertGreaterEqual(len(headers), 2) headers_raw = config['auth']['headers_raw'].split('\n') with mock.patch('builtins.input', side_effect=(headers_raw + [EOFError()])): - headers = setup(headers_browser) + headers = main() self.assertGreaterEqual(len(headers), 2) # @unittest.skip("Cannot test oauth flow non-interactively") @mock.patch('requests.Response.json') @mock.patch('requests.Session.post') + @mock.patch('sys.argv', ["ytmusicapi", "oauth", "--file", headers_oauth]) def test_setup_oauth(self, session_mock, json_mock): session_mock.return_value = Response() json_mock.side_effect = [ @@ -57,8 +59,7 @@ def test_setup_oauth(self, session_mock, json_mock): json.loads(config['auth']['oauth_token']) ] with mock.patch('builtins.input', return_value='y'): - headers = setup_oauth(headers_oauth) - self.assertEqual(len(headers), 6) + main() self.assertTrue(Path(headers_oauth).exists()) ############### @@ -477,7 +478,7 @@ def test_get_library_upload_album(self): def test_get_library_upload_artist(self): tracks = self.yt_oauth.get_library_upload_artist(config['uploads']['private_artist_id'], - 100) + 100) self.assertGreater(len(tracks), 0) diff --git a/ytmusicapi/__init__.py b/ytmusicapi/__init__.py index 6f505ef2..63322764 100644 --- a/ytmusicapi/__init__.py +++ b/ytmusicapi/__init__.py @@ -8,7 +8,7 @@ # package is not installed pass -__copyright__ = 'Copyright 2022 sigma67' +__copyright__ = 'Copyright 2023 sigma67' __license__ = 'MIT' __title__ = 'ytmusicapi' __all__ = ["YTMusic", "setup_oauth", "setup"] diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 9934f313..bec7a56d 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -789,12 +789,12 @@ def get_tasteprofile(self) -> Dict: def set_tasteprofile(self, artists: List[str], taste_profile: Dict = None) -> None: """ Favorites artists to see more recommendations from the artist. - Use get_tasteprofile() to see which artists are available to be recommended + Use :py:func:`get_tasteprofile` to see which artists are available to be recommended :param artists: A List with names of artists, must be contained in the tasteprofile :param taste_profile: tasteprofile result from :py:func:`get_tasteprofile`. Pass this if you call :py:func:`get_tasteprofile` anyway to save an extra request. - :return None if successful + :return: None if successful """ if taste_profile is None: diff --git a/ytmusicapi/setup.py b/ytmusicapi/setup.py index b6b9df62..be1fdd7d 100644 --- a/ytmusicapi/setup.py +++ b/ytmusicapi/setup.py @@ -1,3 +1,6 @@ +import argparse +import sys +from pathlib import Path from typing import Dict import requests @@ -37,5 +40,19 @@ def setup_oauth(filepath: str = None, return YTMusicOAuth(session, proxies).setup(filepath) -if __name__ == "__main__": - setup_oauth() +def parse_args(args): + parser = argparse.ArgumentParser(description='Setup ytmusicapi.') + parser.add_argument("setup_type", + type=str, + choices=["oauth", "browser"], + help="choose a setup type.") + parser.add_argument("--file", type=Path, help="optional path to output file.") + return parser.parse_args(args) + + +def main(): + args = parse_args(sys.argv[1:]) + filename = args.file if args.file else f"{args.setup_type}.json" + print(f"Creating {filename} with your authentication credentials...") + func = setup_oauth if args.setup_type == "oauth" else setup + return func(filename) From 461783f7191cd3e3c7d2e8e1e3d930ea0f491a47 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 5 Apr 2023 23:23:11 +0200 Subject: [PATCH 135/238] update docs --- README.rst | 2 +- docs/source/conf.py | 4 -- docs/source/faq.rst | 2 +- docs/source/index.rst | 9 +--- docs/source/reference.rst | 8 ++-- docs/source/{setup.rst => setup/browser.rst} | 42 +++++++++---------- .../{ => setup}/headers_auth.json.example | 0 docs/source/setup/index.rst | 24 +++++++++++ docs/source/setup/oauth.rst | 15 +++++++ docs/source/usage.rst | 6 +-- 10 files changed, 69 insertions(+), 43 deletions(-) rename docs/source/{setup.rst => setup/browser.rst} (69%) rename docs/source/{ => setup}/headers_auth.json.example (100%) create mode 100644 docs/source/setup/index.rst create mode 100644 docs/source/setup/oauth.rst diff --git a/README.rst b/README.rst index 90d026b2..04955e3c 100644 --- a/README.rst +++ b/README.rst @@ -72,7 +72,7 @@ Usage from ytmusicapi import YTMusic - yt = YTMusic('headers_auth.json') + yt = YTMusic('oauth.json') playlistId = yt.create_playlist('test', 'test description') search_results = yt.search('Oasis Wonderwall') yt.add_playlist_items(playlistId, [search_results[0]['videoId']]) diff --git a/docs/source/conf.py b/docs/source/conf.py index 6610745d..88c05c1b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -68,7 +68,3 @@ # Set theme to 'default' for ReadTheDocs. html_theme = 'default' -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] diff --git a/docs/source/faq.rst b/docs/source/faq.rst index 73a03b17..015ee6ab 100644 --- a/docs/source/faq.rst +++ b/docs/source/faq.rst @@ -74,7 +74,7 @@ A ``browseId`` is an internal, globally unique identifier used by YouTube Music Why is ytmusicapi returning more results than requested with the limit parameter? -*********************************************************************** +********************************************************************************* YouTube Music always returns increments of a specific pagination value, usually between 20 and 100 items at a time. This is the case if a ytmusicapi method supports the ``limit`` parameter. The default value of the ``limit`` parameter indicates the server-side pagination increment. ytmusicapi will keep fetching continuations from the server until it has diff --git a/docs/source/index.rst b/docs/source/index.rst index ef8fcb6f..f30b893d 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,8 +1,3 @@ -.. ytmusicapi documentation master file, created by - sphinx-quickstart on Fri Feb 21 12:48:31 2020. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - ytmusicapi: Unofficial API for YouTube Music ================================================== The purpose of this library is to automate interactions with `YouTube Music `_, @@ -15,7 +10,7 @@ To achieve this, it emulates web requests that would occur if you performed the :start-after: features :end-before: end-features -To **get started**, read the :doc:`setup instructions `. +To **get started**, read the :doc:`setup instructions `. For a **complete documentation** of available functions, see the :doc:`Reference `. @@ -29,7 +24,7 @@ Contents .. toctree:: - setup + setup/index usage reference faq diff --git a/docs/source/reference.rst b/docs/source/reference.rst index c3e277c9..09f145a9 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -1,19 +1,19 @@ Reference ================== - - Reference for the YTMusic class. .. currentmodule:: ytmusicapi .. autoclass:: YTMusic .. automethod:: YTMusic.__init__ + Setup ----- -See also the :doc:`Setup ` page +See also the :doc:`Setup ` page -.. automethod:: YTMusic.setup +.. autofunction:: setup +.. autofunction:: setup_oauth Search ------ diff --git a/docs/source/setup.rst b/docs/source/setup/browser.rst similarity index 69% rename from docs/source/setup.rst rename to docs/source/setup/browser.rst index 82db098e..e08ae0ce 100644 --- a/docs/source/setup.rst +++ b/docs/source/setup/browser.rst @@ -1,17 +1,12 @@ -Setup -===== +Browser authentication +====================== -Installation ------------- -.. code-block:: bash - - pip install ytmusicapi - -Authenticated requests ----------------------- +This method of authentication emulates your browser session by reusing its request headers. +Follow the instructions to have your browser's YouTube Music session request headers parsed +to a ``ytmusicapi`` configuration file. Copy authentication headers -^^^^^^^^^^^^^^^^^^^^^^^^^^^ +--------------------------- To run authenticated requests, set it up by first copying your request headers from an authenticated POST request in your browser. To do so, follow these steps: @@ -51,24 +46,25 @@ To do so, follow these steps:
Using the headers in your project -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +--------------------------------- -To set up your project, open a Python console and call :py:func:`YTMusic.setup` with the parameter ``filepath=headers_auth.json`` and follow the instructions and paste the request headers to the terminal input: +To set up your project, open a console and call -.. code-block:: python +.. code-block:: bash + + ytmusicapi browser - from ytmusicapi import YTMusic - YTMusic.setup(filepath="headers_auth.json") +Follow the instructions and paste the request headers to the terminal input. If you don't want terminal interaction in your project, you can pass the request headers with the ``headers_raw`` parameter: .. code-block:: python - from ytmusicapi import YTMusic - YTMusic.setup(filepath="headers_auth.json", headers_raw="") - -The function returns a JSON string with the credentials needed for :doc:`Usage `. Alternatively, if you passed the filepath parameter as described above, -a file called ``headers_auth.json`` will be created in the current directory, which you can pass to ``YTMusic()`` for authentication. + import ytmusicapi + ytmusicapi.setup(filepath="browser.json", headers_raw="") + +The function returns a JSON string with the credentials needed for :doc:`../usage`. Alternatively, if you passed the filepath parameter as described above, +a file called ``browser.json`` will be created in the current directory, which you can pass to ``YTMusic()`` for authentication. These credentials remain valid as long as your YTMusic browser session is valid (about 2 years unless you log out). @@ -80,7 +76,7 @@ These credentials remain valid as long as your YTMusic browser session is valid .. container:: - MacOS terminal application can only accept 1024 characters pasted to std input. To paste in terminal, a small utility called pbpaste must be used. - - In terminal just prefix the command used to run the script you created above with ``pbpaste | `` + - In terminal just prefix the command used to run the script you created above with :kbd:`pbpaste | ` - This will pipe the contents of the clipboard into the script just as if you had pasted it from the edit menu. .. raw:: html @@ -90,7 +86,7 @@ These credentials remain valid as long as your YTMusic browser session is valid Manual file creation -------------------- -Alternatively, you can paste the cookie to ``headers_auth.json`` below and create your own file: +Alternatively, you can create your own file ``browser.json`` and paste the cookie: .. literalinclude:: headers_auth.json.example :language: JSON diff --git a/docs/source/headers_auth.json.example b/docs/source/setup/headers_auth.json.example similarity index 100% rename from docs/source/headers_auth.json.example rename to docs/source/setup/headers_auth.json.example diff --git a/docs/source/setup/index.rst b/docs/source/setup/index.rst new file mode 100644 index 00000000..056bfdf9 --- /dev/null +++ b/docs/source/setup/index.rst @@ -0,0 +1,24 @@ +Setup +===== + +To install, run: + +.. code-block:: bash + + pip install ytmusicapi + + +Further setup is only needed if you want to access account data using authenticated requests. + +The simplest way of authentication is to use :doc:`oauth`. + +However, these OAuth credentials do not work for uploads. If you need to upload music, +instead follow the instructions at :doc:`browser`. + + + +.. toctree:: + :caption: Table of Contents + + oauth + browser \ No newline at end of file diff --git a/docs/source/setup/oauth.rst b/docs/source/setup/oauth.rst new file mode 100644 index 00000000..62e6bbdb --- /dev/null +++ b/docs/source/setup/oauth.rst @@ -0,0 +1,15 @@ +OAuth authentication +==================== + +After you have installed ``ytmusicapi``, simply run + +.. code-block:: bash + + ytmusicapi oauth + +and follow the instructions. This will create a file ``oauth.json`` in the current directory. + +You can pass this file to :py:class:`YTMusic` as explained in :doc:`../usage`. + +This OAuth flow uses the +`Google API flow for TV devices `_. \ No newline at end of file diff --git a/docs/source/usage.rst b/docs/source/usage.rst index e28b22e1..0ba69a53 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -17,14 +17,14 @@ If an endpoint requires authentication you will receive an error: Authenticated ------------- -For authenticated requests you need to set up your credentials first: :doc:`Setup ` +For authenticated requests you need to set up your credentials first: :doc:`Setup ` After you have created the authentication JSON, you can instantiate the class: .. code-block:: python from ytmusicapi import YTMusic - ytmusic = YTMusic('headers_auth.json') + ytmusic = YTMusic("oauth.json") With the :code:`ytmusic` instance you can now perform authenticated requests: @@ -47,6 +47,6 @@ Example: .. code-block:: python from ytmusicapi import YTMusic - ytmusic = YTMusic('headers_auth.json', "101234161234936123473") + ytmusic = YTMusic("oauth.json", "101234161234936123473") From adc802da0a324c265dea8c657decaba63fa34400 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 5 Apr 2023 23:43:38 +0200 Subject: [PATCH 136/238] search: fix filtering param (#374) --- ytmusicapi/parsers/search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index b1a8d5f7..46ff5cba 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -185,7 +185,7 @@ def get_search_params(filter, scope, ignore_spelling): param1 = filtered_param1 param2 = _get_param2(filter) if not ignore_spelling: - param3 = 'AWoMEA4QChADEAQQCRAF' + param3 = 'AWoKEAMQCRAEEAoQBQ%3D%3D' else: param3 = 'AUICCAFqDBAOEAoQAxAEEAkQBQ%3D%3D' From a1e466273d44e711318ec238bfe933e2639f8c87 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 5 Apr 2023 23:49:17 +0200 Subject: [PATCH 137/238] update FAQ (closes #353) --- docs/source/faq.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/source/faq.rst b/docs/source/faq.rst index 015ee6ab..ad7d387a 100644 --- a/docs/source/faq.rst +++ b/docs/source/faq.rst @@ -73,6 +73,18 @@ What is a browseId? A ``browseId`` is an internal, globally unique identifier used by YouTube Music for browsable content. +Which videoTypes exist and what do they mean? +*********************************************************************** + +``videoType`` is prefixed with ``MUSIC_VIDEO_TYPE_``, i.e. ``MUSIC_VIDEO_TYPE_OMV``. +Currently the following variants of ``videoType`` are known: + +- ``OMV``: Original Music Video - uploaded by original artist with actual video content +- ``UGC``: User Generated Content - uploaded by regular YouTube user +- ``ATV``: High quality song uploaded by original artist with cover image +- ``OFFICIAL_SOURCE_MUSIC``: Official video content, but not for a single track + + Why is ytmusicapi returning more results than requested with the limit parameter? ********************************************************************************* YouTube Music always returns increments of a specific pagination value, usually between 20 and 100 items at a time. From 6ea74e39fbec486d982e30f13ad860c2d1254ca1 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 6 Apr 2023 22:43:22 +0200 Subject: [PATCH 138/238] fix search (#374) --- tests/test.py | 3 +++ ytmusicapi/mixins/search.py | 6 +++--- ytmusicapi/parsers/search.py | 6 +++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test.py b/tests/test.py index b5a60ae8..706beed4 100644 --- a/tests/test.py +++ b/tests/test.py @@ -80,16 +80,19 @@ def test_search(self): for q in queries: with self.subTest(): results = self.yt_brand.search(q) + self.assertListEqual(['resultType' in r for r in results], [True] * len(results)) self.assertGreater(len(results), 10) results = self.yt.search(q) self.assertGreater(len(results), 10) results = self.yt_auth.search('Martin Stig Andersen - Deteriation', ignore_spelling=True) self.assertGreater(len(results), 0) results = self.yt_auth.search(query, filter='songs') + self.assertListEqual([r['resultType'] for r in results], ['song'] * len(results)) self.assertGreater(len(results), 10) results = self.yt_auth.search(query, filter='videos') self.assertGreater(len(results), 10) results = self.yt_auth.search(query, filter='albums', limit=40) + self.assertListEqual([r['resultType'] for r in results], ['album'] * len(results)) self.assertGreater(len(results), 20) results = self.yt_auth.search('project-2', filter='artists', ignore_spelling=True) self.assertGreater(len(results), 0) diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index c5cbb354..8a3fe197 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -198,9 +198,9 @@ def search(self, else: continue + search_result_types = self.parser.get_search_result_types() search_results.extend( - parse_search_results(results, self.parser.get_search_result_types(), type, - category)) + parse_search_results(results, search_result_types, type, category)) if filter: # if filter is set, there are continuations @@ -208,7 +208,7 @@ def request_func(additionalParams): return self._send_request(endpoint, body, additionalParams) def parse_func(contents): - return parse_search_results(contents, type, category) + return parse_search_results(contents, search_result_types, type, category) search_results.extend( get_continuations(res['musicShelfRenderer'], 'musicShelfContinuation', diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 46ff5cba..883b58ae 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -46,8 +46,8 @@ def parse_top_result(data, search_result_types): def parse_search_result(data, search_result_types, result_type, category): default_offset = (not result_type) * 2 search_result = {'category': category} - if video_type := nav(data, PLAY_BUTTON + ['playNavigationEndpoint'] + NAVIGATION_VIDEO_TYPE, - True): + video_type = nav(data, PLAY_BUTTON + ['playNavigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) + if not result_type and video_type: result_type = 'song' if video_type == 'MUSIC_VIDEO_TYPE_ATV' else 'video' result_type = get_search_result_type(get_item_text(data, 1), @@ -185,7 +185,7 @@ def get_search_params(filter, scope, ignore_spelling): param1 = filtered_param1 param2 = _get_param2(filter) if not ignore_spelling: - param3 = 'AWoKEAMQCRAEEAoQBQ%3D%3D' + param3 = 'AWoMEA4QChADEAQQCRAF' else: param3 = 'AUICCAFqDBAOEAoQAxAEEAkQBQ%3D%3D' From 69a419f0bda430a0b51221861d521c5a060bc2dc Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 6 Apr 2023 23:00:04 +0200 Subject: [PATCH 139/238] oauth: add safeguard exception to upload_song --- tests/test.cfg.example | 3 +++ tests/test.py | 1 + ytmusicapi/mixins/uploads.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/tests/test.cfg.example b/tests/test.cfg.example index d2dd8e9c..25cf53ef 100644 --- a/tests/test.cfg.example +++ b/tests/test.cfg.example @@ -4,6 +4,9 @@ brand_account_empty = 1123456629123420379537 headers = headers_auth_json_as_string headers_empty = headers_account_with_empty_library_as_json_as_string headers_file = ./headers_auth.json +headers_oauth = ./oauth.json +oauth_code = {"device_code":"","user_code":"","expires_in":1800,"interval":5,"verification_url":"https://www.google.com/device"} +oauth_token = {"access_token":"","expires_in":1000,"refresh_token":"","scope":"https://www.googleapis.com/auth/youtube","token_type":"Bearer"} headers_raw = raw_headers_pasted_from_browser [playlists] diff --git a/tests/test.py b/tests/test.py index 706beed4..522d2d6f 100644 --- a/tests/test.py +++ b/tests/test.py @@ -466,6 +466,7 @@ def test_get_library_upload_artists(self): def test_upload_song(self): self.assertRaises(Exception, self.yt_auth.upload_song, 'song.wav') + self.assertRaises(Exception, self.yt_oauth.upload_song, config['uploads']['file']) response = self.yt_auth.upload_song(get_resource(config['uploads']['file'])) self.assertEqual(response.status_code, 409) diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index 7f49d21a..e63bb19d 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -194,6 +194,8 @@ def upload_song(self, filepath: str) -> Union[str, requests.Response]: :return: Status String or full response """ self._check_auth() + if not self.is_browser_auth: + raise Exception("Please provide authentication before using this function") if not os.path.isfile(filepath): raise Exception("The provided file does not exist.") From 4251896c39078236d87cbdd16e2f0edf5631fb87 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 6 Apr 2023 23:15:40 +0200 Subject: [PATCH 140/238] fix test issue when running from US (episodes saved for later playlist) --- tests/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test.py b/tests/test.py index 522d2d6f..23886e27 100644 --- a/tests/test.py +++ b/tests/test.py @@ -278,7 +278,7 @@ def test_get_library_playlists(self): self.assertGreaterEqual(len(playlists), config.getint('limits', 'library_playlists')) playlists = self.yt_empty.get_library_playlists() - self.assertEqual(len(playlists), 0) + self.assertLessEqual(len(playlists), 1) # "Episodes saved for later" def test_get_library_songs(self): self.assertRaises(Exception, self.yt_auth.get_library_songs, None, True) From defb621407a3d50cadb12c9e24d439b140f21397 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Fri, 7 Apr 2023 21:12:15 +0200 Subject: [PATCH 141/238] search: don't return empty dict if browseid is missing --- ytmusicapi/parsers/search.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 883b58ae..58ef5c93 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -126,8 +126,6 @@ def parse_search_result(data, search_result_types, result_type, category): if result_type in ['artist', 'album', 'playlist']: search_result['browseId'] = nav(data, NAVIGATION_BROWSE_ID, True) - if not search_result['browseId']: - return {} if result_type in ['song', 'album']: search_result['isExplicit'] = nav(data, BADGE_LABEL, True) is not None From af1cc6e9f848c3fb04c89bc30da1fa82b44bbc7d Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 15 Apr 2023 10:50:29 +0200 Subject: [PATCH 142/238] oauth: move input() out of get_code (#378) --- ytmusicapi/auth/oauth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index c866a130..3a657e30 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -23,8 +23,6 @@ def _send_request(self, url, data) -> requests.Response: def get_code(self) -> Dict: code_response = self._send_request(OAUTH_CODE_URL, data={"scope": OAUTH_SCOPE}) response_json = code_response.json() - url = f"{response_json['verification_url']}?user_code={response_json['user_code']}" - input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") return response_json def get_token_from_code(self, device_code: str) -> Dict: @@ -54,6 +52,8 @@ def dump_token(token, filepath): def setup(self, filepath: Optional[str] = None) -> Dict: code = self.get_code() + url = f"{code['verification_url']}?user_code={code['user_code']}" + input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") token = self.get_token_from_code(code["device_code"]) if filepath: self.dump_token(token, filepath) From e457402633a9df38a07cf734603b4ae1fbc466d2 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 19 Apr 2023 20:45:50 +0200 Subject: [PATCH 143/238] streamline authentication (closes #376, closes #380) --- tests/test.py | 311 ++++++++++++++++++++----------------- ytmusicapi/auth/browser.py | 20 ++- ytmusicapi/auth/headers.py | 43 +++-- ytmusicapi/auth/oauth.py | 48 ++++-- 4 files changed, 245 insertions(+), 177 deletions(-) diff --git a/tests/test.py b/tests/test.py index 23886e27..b322bda4 100644 --- a/tests/test.py +++ b/tests/test.py @@ -1,11 +1,13 @@ -import json import configparser +import json import time import unittest +import warnings from pathlib import Path -from requests import Response from unittest import mock +from requests import Response + from ytmusicapi.setup import main, setup # noqa: E402 from ytmusicapi.ytmusic import YTMusic # noqa: E402 @@ -16,49 +18,53 @@ def get_resource(file: str) -> str: config = configparser.RawConfigParser() -config.read(get_resource('test.cfg'), 'utf-8') +config.read(get_resource("test.cfg"), "utf-8") sample_album = "MPREb_4pL8gzRtw1p" # Eminem - Revival sample_video = "hpSrLjc5SMs" # Oasis - Wonderwall sample_playlist = "PL6bPxvf5dW5clc3y9wAoslzqUrmkZ5c-u" # very large playlist -headers_oauth = get_resource(config['auth']['headers_oauth']) -headers_browser = get_resource(config['auth']['headers_file']) +headers_oauth = get_resource(config["auth"]["headers_oauth"]) +headers_browser = get_resource(config["auth"]["headers_file"]) class TestYTMusic(unittest.TestCase): @classmethod def setUpClass(cls): + warnings.filterwarnings(action="ignore", message="unclosed", category=ResourceWarning) with YTMusic(requests_session=False) as yt: assert isinstance(yt, YTMusic) cls.yt = YTMusic() cls.yt_oauth = YTMusic(headers_oauth) + with open(headers_oauth, mode="r", encoding="utf8") as headers: + string_headers = headers.read() + cls.yt_oauth = YTMusic(string_headers) cls.yt_auth = YTMusic(headers_browser) - cls.yt_brand = YTMusic(config['auth']['headers'], config['auth']['brand_account']) - cls.yt_empty = YTMusic(config['auth']['headers_empty'], - config['auth']['brand_account_empty']) + cls.yt_brand = YTMusic(config["auth"]["headers"], config["auth"]["brand_account"]) + cls.yt_empty = YTMusic(config["auth"]["headers_empty"], + config["auth"]["brand_account_empty"]) - @mock.patch('sys.argv', ["ytmusicapi", "browser", "--file", headers_browser]) + @mock.patch("sys.argv", ["ytmusicapi", "browser", "--file", headers_browser]) def test_setup_browser(self): - headers = setup(headers_browser, config['auth']['headers_raw']) + headers = setup(headers_browser, config["auth"]["headers_raw"]) self.assertGreaterEqual(len(headers), 2) - headers_raw = config['auth']['headers_raw'].split('\n') - with mock.patch('builtins.input', side_effect=(headers_raw + [EOFError()])): + headers_raw = config["auth"]["headers_raw"].split("\n") + with mock.patch("builtins.input", side_effect=(headers_raw + [EOFError()])): headers = main() self.assertGreaterEqual(len(headers), 2) # @unittest.skip("Cannot test oauth flow non-interactively") - @mock.patch('requests.Response.json') - @mock.patch('requests.Session.post') - @mock.patch('sys.argv', ["ytmusicapi", "oauth", "--file", headers_oauth]) + @mock.patch("requests.Response.json") + @mock.patch("requests.Session.post") + @mock.patch("sys.argv", ["ytmusicapi", "oauth", "--file", headers_oauth]) def test_setup_oauth(self, session_mock, json_mock): session_mock.return_value = Response() json_mock.side_effect = [ - json.loads(config['auth']['oauth_code']), - json.loads(config['auth']['oauth_token']) + json.loads(config["auth"]["oauth_code"]), + json.loads(config["auth"]["oauth_token"]), ] - with mock.patch('builtins.input', return_value='y'): + with mock.patch("builtins.input", return_value="y"): main() self.assertTrue(Path(headers_oauth).exists()) @@ -80,53 +86,55 @@ def test_search(self): for q in queries: with self.subTest(): results = self.yt_brand.search(q) - self.assertListEqual(['resultType' in r for r in results], [True] * len(results)) + self.assertListEqual(["resultType" in r for r in results], [True] * len(results)) self.assertGreater(len(results), 10) results = self.yt.search(q) self.assertGreater(len(results), 10) - results = self.yt_auth.search('Martin Stig Andersen - Deteriation', ignore_spelling=True) + results = self.yt_auth.search("Martin Stig Andersen - Deteriation", ignore_spelling=True) self.assertGreater(len(results), 0) - results = self.yt_auth.search(query, filter='songs') - self.assertListEqual([r['resultType'] for r in results], ['song'] * len(results)) + results = self.yt_auth.search(query, filter="songs") + self.assertListEqual([r["resultType"] for r in results], ["song"] * len(results)) self.assertGreater(len(results), 10) - results = self.yt_auth.search(query, filter='videos') + results = self.yt_auth.search(query, filter="videos") self.assertGreater(len(results), 10) - results = self.yt_auth.search(query, filter='albums', limit=40) - self.assertListEqual([r['resultType'] for r in results], ['album'] * len(results)) + results = self.yt_auth.search(query, filter="albums", limit=40) + self.assertListEqual([r["resultType"] for r in results], ["album"] * len(results)) self.assertGreater(len(results), 20) - results = self.yt_auth.search('project-2', filter='artists', ignore_spelling=True) + results = self.yt_auth.search("project-2", filter="artists", ignore_spelling=True) self.assertGreater(len(results), 0) - results = self.yt_auth.search("classical music", filter='playlists') + results = self.yt_auth.search("classical music", filter="playlists") self.assertGreater(len(results), 5) - results = self.yt_auth.search("clasical music", filter='playlists', ignore_spelling=True) + results = self.yt_auth.search("clasical music", filter="playlists", ignore_spelling=True) self.assertGreater(len(results), 5) results = self.yt_auth.search("clasic rock", - filter='community_playlists', + filter="community_playlists", ignore_spelling=True) self.assertGreater(len(results), 5) - results = self.yt_auth.search("hip hop", filter='featured_playlists') + results = self.yt_auth.search("hip hop", filter="featured_playlists") self.assertGreater(len(results), 5) def test_search_uploads(self): - self.assertRaises(Exception, - self.yt.search, - 'audiomachine', - filter="songs", - scope='uploads', - limit=40) - results = self.yt_auth.search('audiomachine', scope='uploads', limit=40) + self.assertRaises( + Exception, + self.yt.search, + "audiomachine", + filter="songs", + scope="uploads", + limit=40, + ) + results = self.yt_auth.search("audiomachine", scope="uploads", limit=40) self.assertGreater(len(results), 20) def test_search_library(self): - results = self.yt_oauth.search('garrix', scope='library') + results = self.yt_oauth.search("garrix", scope="library") self.assertGreater(len(results), 5) - results = self.yt_auth.search('bergersen', filter='songs', scope='library', limit=40) + results = self.yt_auth.search("bergersen", filter="songs", scope="library", limit=40) self.assertGreater(len(results), 10) - results = self.yt_auth.search('garrix', filter='albums', scope='library', limit=40) + results = self.yt_auth.search("garrix", filter="albums", scope="library", limit=40) self.assertGreaterEqual(len(results), 4) - results = self.yt_auth.search('garrix', filter='artists', scope='library', limit=40) + results = self.yt_auth.search("garrix", filter="artists", scope="library", limit=40) self.assertGreaterEqual(len(results), 1) - results = self.yt_auth.search('garrix', filter='playlists', scope='library') + results = self.yt_auth.search("garrix", filter="playlists", scope="library") self.assertGreaterEqual(len(results), 1) def test_get_artist(self): @@ -134,26 +142,28 @@ def test_get_artist(self): self.assertEqual(len(results), 14) # test correctness of related artists - related = results['related']['results'] + related = results["related"]["results"] self.assertEqual( len([ x for x in related if set(x.keys()) == {"browseId", "subscribers", "title", "thumbnails"} - ]), len(related)) + ]), + len(related), + ) results = self.yt.get_artist("UCLZ7tlKC06ResyDmEStSrOw") # no album year self.assertGreaterEqual(len(results), 11) def test_get_artist_albums(self): artist = self.yt.get_artist("UCj5ZiBBqpe0Tg4zfKGHEFuQ") - results = self.yt.get_artist_albums(artist['albums']['browseId'], - artist['albums']['params']) + results = self.yt.get_artist_albums(artist["albums"]["browseId"], + artist["albums"]["params"]) self.assertGreater(len(results), 0) def test_get_artist_singles(self): artist = self.yt_auth.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") - results = self.yt_auth.get_artist_albums(artist['singles']['browseId'], - artist['singles']['params']) + results = self.yt_auth.get_artist_albums(artist["singles"]["browseId"], + artist["singles"]["params"]) self.assertGreater(len(results), 0) def test_get_user(self): @@ -163,7 +173,7 @@ def test_get_user(self): def test_get_user_playlists(self): results = self.yt.get_user("UCPVhZsC2od1xjGhgEc2NEPQ") results = self.yt.get_user_playlists("UCPVhZsC2od1xjGhgEc2NEPQ", - results['playlists']['params']) + results["playlists"]["params"]) self.assertGreater(len(results), 100) def test_get_album_browse_id(self): @@ -173,17 +183,17 @@ def test_get_album_browse_id(self): def test_get_album(self): results = self.yt_auth.get_album(sample_album) self.assertGreaterEqual(len(results), 9) - self.assertTrue(results['tracks'][0]['isExplicit']) - self.assertIn('feedbackTokens', results['tracks'][0]) - self.assertEqual(len(results['other_versions']), 2) + self.assertTrue(results["tracks"][0]["isExplicit"]) + self.assertIn("feedbackTokens", results["tracks"][0]) + self.assertEqual(len(results["other_versions"]), 2) results = self.yt.get_album("MPREb_BQZvl3BFGay") - self.assertEqual(len(results['tracks']), 7) + self.assertEqual(len(results["tracks"]), 7) def test_get_song(self): - song = self.yt_oauth.get_song(config['uploads']['private_upload_id']) # private upload + song = self.yt_oauth.get_song(config["uploads"]["private_upload_id"]) # private upload self.assertEqual(len(song), 5) song = self.yt.get_song(sample_video) - self.assertGreaterEqual(len(song['streamingData']['adaptiveFormats']), 10) + self.assertGreaterEqual(len(song["streamingData"]["adaptiveFormats"]), 10) def test_get_song_related_content(self): song = self.yt_oauth.get_watch_playlist(sample_video) @@ -196,7 +206,7 @@ def test_get_lyrics(self): self.assertIsNotNone(lyrics_song["lyrics"]) self.assertIsNotNone(lyrics_song["source"]) - playlist = self.yt.get_watch_playlist(config['uploads']['private_upload_id']) + playlist = self.yt.get_watch_playlist(config["uploads"]["private_upload_id"]) self.assertIsNone(playlist["lyrics"]) self.assertRaises(Exception, self.yt.get_lyrics, playlist["lyrics"]) @@ -242,9 +252,9 @@ def test_get_mood_playlists(self): def test_get_charts(self): charts = self.yt_oauth.get_charts() self.assertEqual(len(charts), 4) - charts = self.yt.get_charts(country='US') + charts = self.yt.get_charts(country="US") self.assertEqual(len(charts), 5) - charts = self.yt.get_charts(country='BE') + charts = self.yt.get_charts(country="BE") self.assertEqual(len(charts), 4) ############### @@ -253,18 +263,21 @@ def test_get_charts(self): def test_get_watch_playlist(self): playlist = self.yt_oauth.get_watch_playlist( - playlistId="RDAMPLOLAK5uy_l_fKDQGOUsk8kbWsm9s86n4-nZNd2JR8Q", radio=True, limit=90) - self.assertGreaterEqual(len(playlist['tracks']), 90) + playlistId="RDAMPLOLAK5uy_l_fKDQGOUsk8kbWsm9s86n4-nZNd2JR8Q", + radio=True, + limit=90, + ) + self.assertGreaterEqual(len(playlist["tracks"]), 90) playlist = self.yt_oauth.get_watch_playlist("9mWr4c_ig54", limit=50) - self.assertGreater(len(playlist['tracks']), 45) + self.assertGreater(len(playlist["tracks"]), 45) playlist = self.yt_oauth.get_watch_playlist("UoAf_y9Ok4k") # private track - self.assertGreaterEqual(len(playlist['tracks']), 25) + self.assertGreaterEqual(len(playlist["tracks"]), 25) playlist = self.yt.get_watch_playlist( playlistId="OLAK5uy_lKgoGvlrWhX0EIPavQUXxyPed8Cj38AWc", shuffle=True) - self.assertEqual(len(playlist['tracks']), 12) - playlist = self.yt_brand.get_watch_playlist(playlistId=config['playlists']['own'], + self.assertEqual(len(playlist["tracks"]), 12) + playlist = self.yt_brand.get_watch_playlist(playlistId=config["playlists"]["own"], shuffle=True) - self.assertEqual(len(playlist['tracks']), 4) + self.assertEqual(len(playlist["tracks"]), 4) ################ # LIBRARY @@ -275,7 +288,7 @@ def test_get_library_playlists(self): self.assertGreater(len(playlists), 25) playlists = self.yt_auth.get_library_playlists(None) - self.assertGreaterEqual(len(playlists), config.getint('limits', 'library_playlists')) + self.assertGreaterEqual(len(playlists), config.getint("limits", "library_playlists")) playlists = self.yt_empty.get_library_playlists() self.assertLessEqual(len(playlists), 1) # "Episodes saved for later" @@ -285,8 +298,8 @@ def test_get_library_songs(self): songs = self.yt_oauth.get_library_songs(100) self.assertGreaterEqual(len(songs), 100) songs = self.yt_auth.get_library_songs(200, validate_responses=True) - self.assertGreaterEqual(len(songs), config.getint('limits', 'library_songs')) - songs = self.yt_auth.get_library_songs(order='a_to_z') + self.assertGreaterEqual(len(songs), config.getint("limits", "library_songs")) + songs = self.yt_auth.get_library_songs(order="a_to_z") self.assertGreaterEqual(len(songs), 25) songs = self.yt_empty.get_library_songs() self.assertEqual(len(songs), 0) @@ -295,12 +308,12 @@ def test_get_library_albums(self): albums = self.yt_oauth.get_library_albums(100) self.assertGreater(len(albums), 50) for album in albums: - self.assertIn('playlistId', album) - albums = self.yt_brand.get_library_albums(100, order='a_to_z') + self.assertIn("playlistId", album) + albums = self.yt_brand.get_library_albums(100, order="a_to_z") self.assertGreater(len(albums), 50) - albums = self.yt_brand.get_library_albums(100, order='z_to_a') + albums = self.yt_brand.get_library_albums(100, order="z_to_a") self.assertGreater(len(albums), 50) - albums = self.yt_brand.get_library_albums(100, order='recently_added') + albums = self.yt_brand.get_library_albums(100, order="recently_added") self.assertGreater(len(albums), 50) albums = self.yt_empty.get_library_albums() self.assertEqual(len(albums), 0) @@ -308,28 +321,28 @@ def test_get_library_albums(self): def test_get_library_artists(self): artists = self.yt_auth.get_library_artists(50) self.assertGreater(len(artists), 40) - artists = self.yt_oauth.get_library_artists(order='a_to_z', limit=50) + artists = self.yt_oauth.get_library_artists(order="a_to_z", limit=50) self.assertGreater(len(artists), 40) artists = self.yt_brand.get_library_artists(limit=None) - self.assertGreater(len(artists), config.getint('limits', 'library_artists')) + self.assertGreater(len(artists), config.getint("limits", "library_artists")) artists = self.yt_empty.get_library_artists() self.assertEqual(len(artists), 0) def test_get_library_subscriptions(self): artists = self.yt_brand.get_library_subscriptions(50) self.assertGreater(len(artists), 40) - artists = self.yt_brand.get_library_subscriptions(order='z_to_a') + artists = self.yt_brand.get_library_subscriptions(order="z_to_a") self.assertGreater(len(artists), 20) artists = self.yt_brand.get_library_subscriptions(limit=None) - self.assertGreater(len(artists), config.getint('limits', 'library_subscriptions')) + self.assertGreater(len(artists), config.getint("limits", "library_subscriptions")) artists = self.yt_empty.get_library_subscriptions() self.assertEqual(len(artists), 0) def test_get_liked_songs(self): songs = self.yt_brand.get_liked_songs(200) - self.assertGreater(len(songs['tracks']), 100) + self.assertGreater(len(songs["tracks"]), 100) songs = self.yt_empty.get_liked_songs() - self.assertEqual(songs['trackCount'], 0) + self.assertEqual(songs["trackCount"], 0) def test_get_history(self): songs = self.yt_oauth.get_history() @@ -341,34 +354,34 @@ def test_manipulate_history_items(self): self.assertEqual(response.status_code, 204) songs = self.yt_auth.get_history() self.assertGreater(len(songs), 0) - response = self.yt_auth.remove_history_items([songs[0]['feedbackToken']]) - self.assertIn('feedbackResponses', response) + response = self.yt_auth.remove_history_items([songs[0]["feedbackToken"]]) + self.assertIn("feedbackResponses", response) def test_rate_song(self): - response = self.yt_auth.rate_song(sample_video, 'LIKE') - self.assertIn('actions', response) - response = self.yt_auth.rate_song(sample_video, 'INDIFFERENT') - self.assertIn('actions', response) + response = self.yt_auth.rate_song(sample_video, "LIKE") + self.assertIn("actions", response) + response = self.yt_auth.rate_song(sample_video, "INDIFFERENT") + self.assertIn("actions", response) def test_edit_song_library_status(self): album = self.yt_brand.get_album(sample_album) response = self.yt_brand.edit_song_library_status( - album['tracks'][2]['feedbackTokens']['add']) - self.assertTrue(response['feedbackResponses'][0]['isProcessed']) + album["tracks"][2]["feedbackTokens"]["add"]) + self.assertTrue(response["feedbackResponses"][0]["isProcessed"]) response = self.yt_brand.edit_song_library_status( - album['tracks'][2]['feedbackTokens']['remove']) - self.assertTrue(response['feedbackResponses'][0]['isProcessed']) + album["tracks"][2]["feedbackTokens"]["remove"]) + self.assertTrue(response["feedbackResponses"][0]["isProcessed"]) def test_rate_playlist(self): - response = self.yt_auth.rate_playlist('OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4', 'LIKE') - self.assertIn('actions', response) - response = self.yt_auth.rate_playlist('OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4', - 'INDIFFERENT') - self.assertIn('actions', response) + response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", "LIKE") + self.assertIn("actions", response) + response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", + "INDIFFERENT") + self.assertIn("actions", response) def test_subscribe_artists(self): - self.yt_auth.subscribe_artists(['UCUDVBtnOQi4c7E8jebpjc9Q', 'UCiMhD4jzUqG-IgPzUmmytRQ']) - self.yt_auth.unsubscribe_artists(['UCUDVBtnOQi4c7E8jebpjc9Q', 'UCiMhD4jzUqG-IgPzUmmytRQ']) + self.yt_auth.subscribe_artists(["UCUDVBtnOQi4c7E8jebpjc9Q", "UCiMhD4jzUqG-IgPzUmmytRQ"]) + self.yt_auth.unsubscribe_artists(["UCUDVBtnOQi4c7E8jebpjc9Q", "UCiMhD4jzUqG-IgPzUmmytRQ"]) ############### # PLAYLISTS @@ -377,114 +390,128 @@ def test_subscribe_artists(self): def test_get_foreign_playlist(self): self.assertRaises(Exception, self.yt.get_playlist, "PLABC") playlist = self.yt.get_playlist(sample_playlist, limit=300, suggestions_limit=7) - self.assertGreater(len(playlist['tracks']), 200) - self.assertNotIn('suggestions', playlist) + self.assertGreater(len(playlist["tracks"]), 200) + self.assertNotIn("suggestions", playlist) playlist = self.yt.get_playlist("RDCLAK5uy_kpxnNxJpPZjLKbL9WgvrPuErWkUxMP6x4", limit=None, related=True) - self.assertGreater(len(playlist['tracks']), 100) - self.assertEqual(len(playlist['related']), 10) + self.assertGreater(len(playlist["tracks"]), 100) + self.assertEqual(len(playlist["related"]), 10) def test_get_owned_playlist(self): - playlist = self.yt_brand.get_playlist(config['playlists']['own'], + playlist = self.yt_brand.get_playlist(config["playlists"]["own"], related=True, suggestions_limit=21) - self.assertLess(len(playlist['tracks']), 100) - self.assertEqual(len(playlist['suggestions']), 21) - self.assertEqual(len(playlist['related']), 10) + self.assertLess(len(playlist["tracks"]), 100) + self.assertEqual(len(playlist["suggestions"]), 21) + self.assertEqual(len(playlist["related"]), 10) def test_edit_playlist(self): - playlist = self.yt_brand.get_playlist(config['playlists']['own']) - response = self.yt_brand.edit_playlist(playlist['id'], - title='', - description='', - privacyStatus='PRIVATE', - moveItem=(playlist['tracks'][1]['setVideoId'], - playlist['tracks'][0]['setVideoId'])) - self.assertEqual(response, 'STATUS_SUCCEEDED', "Playlist edit failed") - self.yt_brand.edit_playlist(playlist['id'], - title=playlist['title'], - description=playlist['description'], - privacyStatus=playlist['privacy'], - moveItem=(playlist['tracks'][0]['setVideoId'], - playlist['tracks'][1]['setVideoId'])) - self.assertEqual(response, 'STATUS_SUCCEEDED', "Playlist edit failed") + playlist = self.yt_brand.get_playlist(config["playlists"]["own"]) + response = self.yt_brand.edit_playlist( + playlist["id"], + title="", + description="", + privacyStatus="PRIVATE", + moveItem=( + playlist["tracks"][1]["setVideoId"], + playlist["tracks"][0]["setVideoId"], + ), + ) + self.assertEqual(response, "STATUS_SUCCEEDED", "Playlist edit failed") + self.yt_brand.edit_playlist( + playlist["id"], + title=playlist["title"], + description=playlist["description"], + privacyStatus=playlist["privacy"], + moveItem=( + playlist["tracks"][0]["setVideoId"], + playlist["tracks"][1]["setVideoId"], + ), + ) + self.assertEqual(response, "STATUS_SUCCEEDED", "Playlist edit failed") # end to end test adding playlist, adding item, deleting item, deleting playlist def test_end2end(self): playlistId = self.yt_brand.create_playlist( "test", "test description", - source_playlist="OLAK5uy_lGQfnMNGvYCRdDq9ZLzJV2BJL2aHQsz9Y") + source_playlist="OLAK5uy_lGQfnMNGvYCRdDq9ZLzJV2BJL2aHQsz9Y", + ) self.assertEqual(len(playlistId), 34, "Playlist creation failed") response = self.yt_brand.add_playlist_items( - playlistId, [sample_video, sample_video], - source_playlist='OLAK5uy_nvjTE32aFYdFN7HCyMv3cGqD3wqBb4Jow', - duplicates=True) - self.assertEqual(response["status"], 'STATUS_SUCCEEDED', "Adding playlist item failed") + playlistId, + [sample_video, sample_video], + source_playlist="OLAK5uy_nvjTE32aFYdFN7HCyMv3cGqD3wqBb4Jow", + duplicates=True, + ) + self.assertEqual(response["status"], "STATUS_SUCCEEDED", "Adding playlist item failed") self.assertGreater(len(response["playlistEditResults"]), 0, "Adding playlist item failed") time.sleep(2) playlist = self.yt_brand.get_playlist(playlistId, related=True) - self.assertEqual(len(playlist['tracks']), 46, "Getting playlist items failed") - response = self.yt_brand.remove_playlist_items(playlistId, playlist['tracks']) - self.assertEqual(response, 'STATUS_SUCCEEDED', "Playlist item removal failed") + self.assertEqual(len(playlist["tracks"]), 46, "Getting playlist items failed") + response = self.yt_brand.remove_playlist_items(playlistId, playlist["tracks"]) + self.assertEqual(response, "STATUS_SUCCEEDED", "Playlist item removal failed") response = self.yt_brand.delete_playlist(playlistId) - self.assertEqual(response['command']['handlePlaylistDeletionCommand']['playlistId'], - playlistId, "Playlist removal failed") + self.assertEqual( + response["command"]["handlePlaylistDeletionCommand"]["playlistId"], + playlistId, + "Playlist removal failed", + ) ############### # UPLOADS ############### def test_get_library_upload_songs(self): - results = self.yt_oauth.get_library_upload_songs(50, order='z_to_a') + results = self.yt_oauth.get_library_upload_songs(50, order="z_to_a") self.assertGreater(len(results), 25) results = self.yt_empty.get_library_upload_songs(100) self.assertEqual(len(results), 0) def test_get_library_upload_albums(self): - results = self.yt_oauth.get_library_upload_albums(50, order='a_to_z') + results = self.yt_oauth.get_library_upload_albums(50, order="a_to_z") self.assertGreater(len(results), 40) albums = self.yt_auth.get_library_upload_albums(None) - self.assertGreaterEqual(len(albums), config.getint('limits', 'library_upload_albums')) + self.assertGreaterEqual(len(albums), config.getint("limits", "library_upload_albums")) results = self.yt_empty.get_library_upload_albums(100) self.assertEqual(len(results), 0) def test_get_library_upload_artists(self): artists = self.yt_oauth.get_library_upload_artists(None) - self.assertGreaterEqual(len(artists), config.getint('limits', 'library_upload_artists')) + self.assertGreaterEqual(len(artists), config.getint("limits", "library_upload_artists")) - results = self.yt_auth.get_library_upload_artists(50, order='recently_added') + results = self.yt_auth.get_library_upload_artists(50, order="recently_added") self.assertGreaterEqual(len(results), 25) results = self.yt_empty.get_library_upload_artists(100) self.assertEqual(len(results), 0) def test_upload_song(self): - self.assertRaises(Exception, self.yt_auth.upload_song, 'song.wav') - self.assertRaises(Exception, self.yt_oauth.upload_song, config['uploads']['file']) - response = self.yt_auth.upload_song(get_resource(config['uploads']['file'])) + self.assertRaises(Exception, self.yt_auth.upload_song, "song.wav") + self.assertRaises(Exception, self.yt_oauth.upload_song, config["uploads"]["file"]) + response = self.yt_auth.upload_song(get_resource(config["uploads"]["file"])) self.assertEqual(response.status_code, 409) @unittest.skip("Do not delete uploads") def test_delete_upload_entity(self): results = self.yt_oauth.get_library_upload_songs() - response = self.yt_oauth.delete_upload_entity(results[0]['entityId']) - self.assertEqual(response, 'STATUS_SUCCEEDED') + response = self.yt_oauth.delete_upload_entity(results[0]["entityId"]) + self.assertEqual(response, "STATUS_SUCCEEDED") def test_get_library_upload_album(self): - album = self.yt_oauth.get_library_upload_album(config['uploads']['private_album_id']) - self.assertGreater(len(album['tracks']), 0) + album = self.yt_oauth.get_library_upload_album(config["uploads"]["private_album_id"]) + self.assertGreater(len(album["tracks"]), 0) def test_get_library_upload_artist(self): - tracks = self.yt_oauth.get_library_upload_artist(config['uploads']['private_artist_id'], + tracks = self.yt_oauth.get_library_upload_artist(config["uploads"]["private_artist_id"], 100) self.assertGreater(len(tracks), 0) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/ytmusicapi/auth/browser.py b/ytmusicapi/auth/browser.py index 70e91f4a..7462fbc0 100644 --- a/ytmusicapi/auth/browser.py +++ b/ytmusicapi/auth/browser.py @@ -1,15 +1,23 @@ import os import platform + +from requests.structures import CaseInsensitiveDict + from ytmusicapi.helpers import * path = os.path.dirname(os.path.realpath(__file__)) + os.sep +def is_browser(headers: CaseInsensitiveDict) -> bool: + browser_structure = {"authorization", "cookie"} + return browser_structure.issubset(headers.keys()) + + def setup_browser(filepath=None, headers_raw=None): contents = [] if not headers_raw: eof = "Ctrl-D" if platform.system() != "Windows" else "'Enter, Ctrl-Z, Enter'" - print("Please paste the request headers from Firefox and press " + eof + " to continue:") + print(f"Please paste the request headers from Firefox and press {eof} to continue:") while True: try: line = input() @@ -17,19 +25,19 @@ def setup_browser(filepath=None, headers_raw=None): break contents.append(line) else: - contents = headers_raw.split('\n') + contents = headers_raw.split("\n") try: user_headers = {} for content in contents: - header = content.split(': ') + header = content.split(": ") if len(header) == 1 or header[0].startswith( ":"): # nothing was split or chromium headers continue - user_headers[header[0].lower()] = ': '.join(header[1:]) + user_headers[header[0].lower()] = ": ".join(header[1:]) except Exception as e: - raise Exception("Error parsing your input, please try again. Full error: " + str(e)) + raise Exception(f"Error parsing your input, please try again. Full error: {e}") from e missing_headers = {"cookie", "x-goog-authuser"} - set(k.lower() for k in user_headers.keys()) if missing_headers: @@ -48,7 +56,7 @@ def setup_browser(filepath=None, headers_raw=None): headers = user_headers if filepath is not None: - with open(filepath, 'w') as file: + with open(filepath, "w") as file: json.dump(headers, file, ensure_ascii=True, indent=4, sort_keys=True) return json.dumps(headers) diff --git a/ytmusicapi/auth/headers.py b/ytmusicapi/auth/headers.py index 5a4a2faa..981dd0b7 100644 --- a/ytmusicapi/auth/headers.py +++ b/ytmusicapi/auth/headers.py @@ -1,30 +1,45 @@ import json import os -from typing import Optional, Dict +from typing import Dict, Optional import requests from requests.structures import CaseInsensitiveDict -from ytmusicapi.auth.oauth import YTMusicOAuth +from ytmusicapi.auth.browser import is_browser +from ytmusicapi.auth.oauth import YTMusicOAuth, is_oauth from ytmusicapi.helpers import initialize_headers -def prepare_headers(session: requests.Session, - proxies: Optional[Dict] = None, - auth: Optional[str] = None) -> Dict: - headers = {} +def load_headers_file(auth: str) -> Dict: + if os.path.isfile(auth): + with open(auth) as json_file: + input_json = json.load(json_file) + else: + input_json = json.loads(auth) + + return input_json + + +def prepare_headers( + session: requests.Session, + proxies: Optional[Dict] = None, + auth: Optional[str] = None, +) -> Dict: if auth: - if os.path.isfile(auth): - with open(auth) as json_file: - input_json = json.load(json_file) - else: - input_json = json.loads(auth) + input_json = load_headers_file(auth) + input_dict = CaseInsensitiveDict(input_json) - if "oauth.json" in auth: + if is_oauth(input_dict): oauth = YTMusicOAuth(session, proxies) - headers = oauth.load_headers(input_json, auth) + headers = oauth.load_headers(dict(input_dict), auth) + + elif is_browser(input_dict): + headers = input_dict + else: - headers = CaseInsensitiveDict(input_json) + raise Exception( + "Could not detect credential type. " + "Please ensure your oauth or browser credentials are set up correctly.") else: # no authentication headers = initialize_headers() diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index 3a657e30..458c2ddf 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -1,12 +1,27 @@ +import json import time from typing import Dict, Optional + import requests -import json +from requests.structures import CaseInsensitiveDict -from ytmusicapi.constants import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_SCOPE, OAUTH_CODE_URL, OAUTH_TOKEN_URL, OAUTH_USER_AGENT +from ytmusicapi.constants import (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, + OAUTH_CODE_URL, OAUTH_SCOPE, OAUTH_TOKEN_URL, + OAUTH_USER_AGENT) from ytmusicapi.helpers import initialize_headers +def is_oauth(headers: CaseInsensitiveDict) -> bool: + oauth_structure = { + "access_token", + "expires_at", + "expires_in", + "token_type", + "refresh_token", + } + return oauth_structure.issubset(headers.keys()) + + class YTMusicOAuth: """OAuth implementation for YouTube Music based on YouTube TV""" @@ -26,22 +41,25 @@ def get_code(self) -> Dict: return response_json def get_token_from_code(self, device_code: str) -> Dict: - token_response = self._send_request(OAUTH_TOKEN_URL, - data={ - "client_secret": OAUTH_CLIENT_SECRET, - "grant_type": - "http://oauth.net/grant_type/device/1.0", - "code": device_code - }) + token_response = self._send_request( + OAUTH_TOKEN_URL, + data={ + "client_secret": OAUTH_CLIENT_SECRET, + "grant_type": "http://oauth.net/grant_type/device/1.0", + "code": device_code, + }, + ) return token_response.json() def refresh_token(self, refresh_token: str) -> Dict: - response = self._send_request(OAUTH_TOKEN_URL, - data={ - "client_secret": OAUTH_CLIENT_SECRET, - "grant_type": "refresh_token", - "refresh_token": refresh_token - }) + response = self._send_request( + OAUTH_TOKEN_URL, + data={ + "client_secret": OAUTH_CLIENT_SECRET, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, + ) return response.json() @staticmethod From 2825e734c563572bd551eaacb66555a64504ccbb Mon Sep 17 00:00:00 2001 From: sigma67 <16363825+sigma67@users.noreply.github.com> Date: Thu, 20 Apr 2023 09:43:51 +0200 Subject: [PATCH 144/238] update coverage.yml run only on branches, not tags --- .github/workflows/coverage.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 2e32cf7c..40164c61 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,7 +1,9 @@ name: Code coverage on: - push + push: + branches: + - '**' jobs: build: From 8aee43837b89715f1589f7a6b23d20ce814f97e1 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 20 Apr 2023 20:05:14 +0200 Subject: [PATCH 145/238] oauth: fix string returned by setup_oauth to include expires_in --- tests/test.py | 9 +++++---- ytmusicapi/auth/oauth.py | 24 +++++++++++++++--------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/tests/test.py b/tests/test.py index b322bda4..271c6afa 100644 --- a/tests/test.py +++ b/tests/test.py @@ -37,9 +37,6 @@ def setUpClass(cls): assert isinstance(yt, YTMusic) cls.yt = YTMusic() cls.yt_oauth = YTMusic(headers_oauth) - with open(headers_oauth, mode="r", encoding="utf8") as headers: - string_headers = headers.read() - cls.yt_oauth = YTMusic(string_headers) cls.yt_auth = YTMusic(headers_browser) cls.yt_brand = YTMusic(config["auth"]["headers"], config["auth"]["brand_account"]) cls.yt_empty = YTMusic(config["auth"]["headers_empty"], @@ -54,7 +51,6 @@ def test_setup_browser(self): headers = main() self.assertGreaterEqual(len(headers), 2) - # @unittest.skip("Cannot test oauth flow non-interactively") @mock.patch("requests.Response.json") @mock.patch("requests.Session.post") @mock.patch("sys.argv", ["ytmusicapi", "oauth", "--file", headers_oauth]) @@ -68,6 +64,11 @@ def test_setup_oauth(self, session_mock, json_mock): main() self.assertTrue(Path(headers_oauth).exists()) + json_mock.side_effect = None + with open(headers_oauth, mode="r", encoding="utf8") as headers: + string_headers = headers.read() + self.yt_oauth = YTMusic(string_headers) + ############### # BROWSING ############### diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index 458c2ddf..69686f0d 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -1,13 +1,13 @@ import json import time +from pathlib import Path from typing import Dict, Optional import requests from requests.structures import CaseInsensitiveDict -from ytmusicapi.constants import (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, - OAUTH_CODE_URL, OAUTH_SCOPE, OAUTH_TOKEN_URL, - OAUTH_USER_AGENT) +from ytmusicapi.constants import (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_CODE_URL, + OAUTH_SCOPE, OAUTH_TOKEN_URL, OAUTH_USER_AGENT) from ytmusicapi.helpers import initialize_headers @@ -40,8 +40,14 @@ def get_code(self) -> Dict: response_json = code_response.json() return response_json + @staticmethod + def _parse_token(response) -> Dict: + token = response.json() + token["expires_at"] = int(time.time()) + int(token["expires_in"]) + return token + def get_token_from_code(self, device_code: str) -> Dict: - token_response = self._send_request( + response = self._send_request( OAUTH_TOKEN_URL, data={ "client_secret": OAUTH_CLIENT_SECRET, @@ -49,7 +55,7 @@ def get_token_from_code(self, device_code: str) -> Dict: "code": device_code, }, ) - return token_response.json() + return self._parse_token(response) def refresh_token(self, refresh_token: str) -> Dict: response = self._send_request( @@ -60,11 +66,12 @@ def refresh_token(self, refresh_token: str) -> Dict: "refresh_token": refresh_token, }, ) - return response.json() + return self._parse_token(response) @staticmethod def dump_token(token, filepath): - token["expires_at"] = int(time.time()) + int(token["expires_in"]) + if not Path(filepath).is_file(): + return with open(filepath, encoding="utf8", mode="w") as file: json.dump(token, file, indent=True) @@ -73,8 +80,7 @@ def setup(self, filepath: Optional[str] = None) -> Dict: url = f"{code['verification_url']}?user_code={code['user_code']}" input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") token = self.get_token_from_code(code["device_code"]) - if filepath: - self.dump_token(token, filepath) + self.dump_token(token, filepath) return token From 9f2b5d6d2e7cff80a4c83efd2eb958937f028fb5 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 20 Apr 2023 21:13:32 +0200 Subject: [PATCH 146/238] oauth: fix dump_token path detection --- ytmusicapi/auth/oauth.py | 5 ++--- ytmusicapi/setup.py | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index 69686f0d..6750ffea 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -1,6 +1,5 @@ import json import time -from pathlib import Path from typing import Dict, Optional import requests @@ -69,8 +68,8 @@ def refresh_token(self, refresh_token: str) -> Dict: return self._parse_token(response) @staticmethod - def dump_token(token, filepath): - if not Path(filepath).is_file(): + def dump_token(token: Dict, filepath: Optional[str]): + if not filepath or len(filepath) > 255: return with open(filepath, encoding="utf8", mode="w") as file: json.dump(token, file, indent=True) diff --git a/ytmusicapi/setup.py b/ytmusicapi/setup.py index be1fdd7d..cad60ad9 100644 --- a/ytmusicapi/setup.py +++ b/ytmusicapi/setup.py @@ -52,7 +52,7 @@ def parse_args(args): def main(): args = parse_args(sys.argv[1:]) - filename = args.file if args.file else f"{args.setup_type}.json" + filename = args.file.as_posix() if args.file else f"{args.setup_type}.json" print(f"Creating {filename} with your authentication credentials...") func = setup_oauth if args.setup_type == "oauth" else setup return func(filename) From 77697972177c743565e463127a20bed9720a852e Mon Sep 17 00:00:00 2001 From: Heimen Stoffels Date: Sun, 23 Apr 2023 14:57:12 +0200 Subject: [PATCH 147/238] Added Dutch translation --- ytmusicapi/locales/nl/LC_MESSAGES/base.po | 59 +++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 ytmusicapi/locales/nl/LC_MESSAGES/base.po diff --git a/ytmusicapi/locales/nl/LC_MESSAGES/base.po b/ytmusicapi/locales/nl/LC_MESSAGES/base.po new file mode 100644 index 00000000..abd94495 --- /dev/null +++ b/ytmusicapi/locales/nl/LC_MESSAGES/base.po @@ -0,0 +1,59 @@ +# Translations for ytmusicapi +# Copyright (C) 2021 sigma67 +# This file is distributed under the same license as ytmusicapi +# sigma67 +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"PO-Revision-Date: 2023-04-23 14:55+0200\n" +"Last-Translator: Heimen Stoffels \n" +"Language-Team: \n" +"Language: nl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.2.2\n" + +#: parsers/browsing.py:22 +msgid "artist" +msgstr "artiest" + +#: parsers/browsing.py:22 +msgid "playlist" +msgstr "afspeellijst" + +#: parsers/browsing.py:23 +msgid "song" +msgstr "nummer" + +#: parsers/browsing.py:23 +msgid "video" +msgstr "video" + +#: parsers/browsing.py:24 +msgid "station" +msgstr "station" + +#: parsers/browsing.py:128 +msgid "albums" +msgstr "albums" + +#: parsers/browsing.py:128 +msgid "singles" +msgstr "singles" + +#: parsers/browsing.py:128 +msgid "videos" +msgstr "video's" + +#: parsers/browsing.py:128 +msgid "playlists" +msgstr "afspeellijsten" + +#: parsers/browsing.py:128 +msgid "related" +msgstr "gerelateerd" From 17414c01ee3869f97c5e0c7db76bc0c0c5fed379 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 1 May 2023 12:17:05 +0200 Subject: [PATCH 148/238] add mo file --- ytmusicapi/locales/nl/LC_MESSAGES/base.mo | Bin 0 -> 771 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 ytmusicapi/locales/nl/LC_MESSAGES/base.mo diff --git a/ytmusicapi/locales/nl/LC_MESSAGES/base.mo b/ytmusicapi/locales/nl/LC_MESSAGES/base.mo new file mode 100644 index 0000000000000000000000000000000000000000..1da550926f4560175f871ad523564bf005d3dd4e GIT binary patch literal 771 zcmZvZ&u-K(5XKFZe*q!Ni31YmL=p0m&9+jNWf3a0rHEBmWs5j)aW}K6E60v(r=kzS zGjQX`3vlJc1N6v^BPT9=-c7X?Vx-B>U+fuw$(-I<`os`7koS-Qxs3ddjB)z{V^_fC zmUqEx(D%XX;0kyZ%v$E)67)u^x53NMyI}0I557izX!Yld^ZGB~P3(UKZ-L*y=;=qh ze+tI>pRN7{#=gH>{Rc$LSztNymVnVq^m_>zBld_9J;(a9hVu~8OuC?aV6~Pf z`uxH(M_L9dm{+FMD^e91 z{x;a@l(gqh(Im*md;ffjvz1i3GqKe(RGPApC5bV~3OAnVoEx1CcZQn_lig05ys##a z!Cfac%mn%vp5SLF)8~^z={$wO{&-8QFV00zQ*vU{Ol*OFlk@ch6_Sx|oYZ2=y2|6K zdM5oK>+|_?uwwXw0q;KQC-22J8FKUQ8d)k-;Jr@P$r5&MDul7fsjrE&R!9E`GOVQe Sgvc#sWSXiX_rEDU^z1JqOSygk literal 0 HcmV?d00001 From 4d5e4b7116d46a3523184c8fcb445669fceedd8a Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 1 May 2023 17:04:06 +0200 Subject: [PATCH 149/238] update docsbuild.yml --- .github/workflows/docsbuild.yml | 6 +++--- docs/source/setup/browser.rst | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docsbuild.yml b/.github/workflows/docsbuild.yml index 282c012c..fe3ddb99 100644 --- a/.github/workflows/docsbuild.yml +++ b/.github/workflows/docsbuild.yml @@ -7,15 +7,15 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v1 + uses: actions/setup-python@v3 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install ytmusicapi sphinx sphinx-rtd-theme + pip install . sphinx sphinx-rtd-theme - name: Build documentation run: | cd docs diff --git a/docs/source/setup/browser.rst b/docs/source/setup/browser.rst index e08ae0ce..b146b525 100644 --- a/docs/source/setup/browser.rst +++ b/docs/source/setup/browser.rst @@ -76,7 +76,8 @@ These credentials remain valid as long as your YTMusic browser session is valid .. container:: - MacOS terminal application can only accept 1024 characters pasted to std input. To paste in terminal, a small utility called pbpaste must be used. - - In terminal just prefix the command used to run the script you created above with :kbd:`pbpaste | ` + - In terminal just prefix the command used to run the script you created above with + ``pbpaste |`` - This will pipe the contents of the clipboard into the script just as if you had pasted it from the edit menu. .. raw:: html From 78353a03a24bb07bae73f7f9f2983db95a62e5da Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 17 May 2023 20:35:31 +0200 Subject: [PATCH 150/238] update README.rst --- README.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 04955e3c..ec93b86c 100644 --- a/README.rst +++ b/README.rst @@ -29,8 +29,6 @@ It emulates YouTube Music web client requests using the user's cookie data for a Features -------- -At this point ytmusicapi supports nearly all content interactions in the YouTube Music web app. -If you find something missing or broken, feel free to create an `issue `_ | **Browsing**: @@ -65,6 +63,9 @@ If you find something missing or broken, feel free to create an `issue `__ or +feel free to create an `issue `__. Usage ------ From 0fc5417ab967400451bcc8365f3912971f1111df Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 17 May 2023 21:01:07 +0200 Subject: [PATCH 151/238] open browser on ytmusic oauth call (closes #384) --- ytmusicapi/auth/oauth.py | 5 ++++- ytmusicapi/setup.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index 6750ffea..5f687fa6 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -1,5 +1,6 @@ import json import time +import webbrowser from typing import Dict, Optional import requests @@ -74,9 +75,11 @@ def dump_token(token: Dict, filepath: Optional[str]): with open(filepath, encoding="utf8", mode="w") as file: json.dump(token, file, indent=True) - def setup(self, filepath: Optional[str] = None) -> Dict: + def setup(self, filepath: Optional[str] = None, open_browser: bool = False) -> Dict: code = self.get_code() url = f"{code['verification_url']}?user_code={code['user_code']}" + if open_browser: + webbrowser.open(url) input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") token = self.get_token_from_code(code["device_code"]) self.dump_token(token, filepath) diff --git a/ytmusicapi/setup.py b/ytmusicapi/setup.py index cad60ad9..7a0edb5f 100644 --- a/ytmusicapi/setup.py +++ b/ytmusicapi/setup.py @@ -24,7 +24,8 @@ def setup(filepath: str = None, headers_raw: str = None) -> Dict: def setup_oauth(filepath: str = None, session: requests.Session = None, - proxies: dict = None) -> Dict: + proxies: dict = None, + open_browser: bool = False) -> Dict: """ Starts oauth flow from the terminal and returns a string that can be passed to YTMusic() @@ -32,12 +33,13 @@ def setup_oauth(filepath: str = None, :param session: Session to use for authentication :param proxies: Proxies to use for authentication :param filepath: Optional filepath to store headers to. + :param open_browser: If True, open the default browser with the setup link :return: configuration headers string """ if not session: session = requests.Session() - return YTMusicOAuth(session, proxies).setup(filepath) + return YTMusicOAuth(session, proxies).setup(filepath, open_browser) def parse_args(args): @@ -54,5 +56,7 @@ def main(): args = parse_args(sys.argv[1:]) filename = args.file.as_posix() if args.file else f"{args.setup_type}.json" print(f"Creating {filename} with your authentication credentials...") - func = setup_oauth if args.setup_type == "oauth" else setup - return func(filename) + if args.setup_type == "oauth": + return setup_oauth(filename, open_browser=True) + else: + return setup(filename) From 9819ddb72b9ad50124575cc108a5cda61b91d843 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 21 May 2023 20:26:33 +0200 Subject: [PATCH 152/238] get_playlist: fix issue with views, limit --- tests/test.py | 11 ++++++----- ytmusicapi/mixins/playlists.py | 36 ++++++++++++++++------------------ 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/tests/test.py b/tests/test.py index 271c6afa..35b16eea 100644 --- a/tests/test.py +++ b/tests/test.py @@ -391,14 +391,15 @@ def test_subscribe_artists(self): def test_get_foreign_playlist(self): self.assertRaises(Exception, self.yt.get_playlist, "PLABC") playlist = self.yt.get_playlist(sample_playlist, limit=300, suggestions_limit=7) + self.assertGreater(len(playlist['duration']), 5) self.assertGreater(len(playlist["tracks"]), 200) self.assertNotIn("suggestions", playlist) - playlist = self.yt.get_playlist("RDCLAK5uy_kpxnNxJpPZjLKbL9WgvrPuErWkUxMP6x4", - limit=None, - related=True) - self.assertGreater(len(playlist["tracks"]), 100) - self.assertEqual(len(playlist["related"]), 10) + playlist = self.yt_oauth.get_playlist("PLj4BSJLnVpNyIjbCWXWNAmybc97FXLlTk", + limit=None, + related=True) + self.assertGreater(len(playlist["tracks"]), 200) + self.assertEqual(len(playlist["related"]), 0) def test_get_owned_playlist(self): playlist = self.yt_brand.get_playlist(config["playlists"]["own"], diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index d668e093..70f0dfef 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -133,20 +133,20 @@ def get_playlist(self, playlist['year'] = nav(header, SUBTITLE3) second_subtitle_runs = header['secondSubtitle']['runs'] - own_offset = (own_playlist and len(second_subtitle_runs) > 3) * 2 - song_count = to_int(second_subtitle_runs[own_offset]['text']) - if len(second_subtitle_runs) > 1: - playlist['duration'] = second_subtitle_runs[own_offset + 2]['text'] + 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 - playlist['views'] = None - if own_playlist: - playlist['views'] = to_int(second_subtitle_runs[0]['text']) request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) # suggestions and related are missing e.g. on liked songs section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) + playlist['related'] = [] if 'continuations' in section_list: additionalParams = get_continuation_params(section_list) if own_playlist and (suggestions_limit > 0 or related): @@ -168,23 +168,21 @@ def get_playlist(self, if related: response = request_func(additionalParams) - continuation = nav(response, SECTION_LIST_CONTINUATION) - parse_func = lambda results: parse_content_list(results, parse_playlist) - playlist['related'] = get_continuation_contents( - nav(continuation, CONTENT + CAROUSEL), parse_func) - - if song_count > 0: + 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'] = [] + if 'contents' in results: playlist['tracks'] = parse_playlist_items(results['contents']) - if limit is None: - limit = song_count - songs_to_get = min(limit, song_count) parse_func = lambda contents: parse_playlist_items(contents) if 'continuations' in results: playlist['tracks'].extend( - get_continuations(results, 'musicPlaylistShelfContinuation', - songs_to_get - len(playlist['tracks']), request_func, - parse_func)) + get_continuations(results, 'musicPlaylistShelfContinuation', limit, + request_func, parse_func)) playlist['duration_seconds'] = sum_total_duration(playlist) return playlist From 5a50c06588277b053d4ade57bc2e1e3ae0f14292 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 21 May 2023 20:35:31 +0200 Subject: [PATCH 153/238] update coverage.yml --- .github/workflows/coverage.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 40164c61..8e5643aa 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -4,6 +4,9 @@ on: push: branches: - '**' + pull_request: + branches: + - master jobs: build: From d9f9fcc1477ead219e7c06c593574ba8cc13fe52 Mon Sep 17 00:00:00 2001 From: czifumasa Date: Thu, 18 May 2023 17:29:09 +0200 Subject: [PATCH 154/238] Fixed unrecognized case sensitive headers during auth --- ytmusicapi/auth/browser.py | 2 +- ytmusicapi/auth/oauth.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ytmusicapi/auth/browser.py b/ytmusicapi/auth/browser.py index 7462fbc0..65c60a05 100644 --- a/ytmusicapi/auth/browser.py +++ b/ytmusicapi/auth/browser.py @@ -10,7 +10,7 @@ def is_browser(headers: CaseInsensitiveDict) -> bool: browser_structure = {"authorization", "cookie"} - return browser_structure.issubset(headers.keys()) + return all(key in headers for key in browser_structure) def setup_browser(filepath=None, headers_raw=None): diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index 5f687fa6..18457457 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -19,7 +19,7 @@ def is_oauth(headers: CaseInsensitiveDict) -> bool: "token_type", "refresh_token", } - return oauth_structure.issubset(headers.keys()) + return all(key in headers for key in oauth_structure) class YTMusicOAuth: From 2c99445d1b595c836daaf46601713fd1cc16ff8e Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 15 Jun 2023 20:07:42 +0200 Subject: [PATCH 155/238] add location support (closes #377) --- docs/source/faq.rst | 240 ++++++++++++++++++++++++++++++++++++++++ ytmusicapi/constants.py | 13 +++ ytmusicapi/ytmusic.py | 22 +++- 3 files changed, 269 insertions(+), 6 deletions(-) diff --git a/docs/source/faq.rst b/docs/source/faq.rst index ad7d387a..239f2925 100644 --- a/docs/source/faq.rst +++ b/docs/source/faq.rst @@ -92,3 +92,243 @@ This is the case if a ytmusicapi method supports the ``limit`` parameter. The de indicates the server-side pagination increment. ytmusicapi will keep fetching continuations from the server until it has reached at least the ``limit`` parameter, and return all of these results. + +Which values can I use for locations? +************************************* + +Pick a value from the list below for your desired location and pass it using the `location` parameter. + +.. raw:: html + +
+ Supported locations + +.. container:: + + .. list-table:: + + * - Location + - Value + * - Algeria + - DZ + * - Argentina + - AR + * - Australia + - AU + * - Austria + - AT + * - Azerbaijan + - AZ + * - Bahrain + - BH + * - Bangladesh + - BD + * - Belarus + - BY + * - Belgium + - BE + * - Bolivia + - BO + * - Bosnia and Herzegovina + - BA + * - Brazil + - BR + * - Bulgaria + - BG + * - Cambodia + - KH + * - Canada + - CA + * - Chile + - CL + * - Colombia + - CO + * - Costa Rica + - CR + * - Croatia + - HR + * - Cyprus + - CY + * - Czechia + - CZ + * - Denmark + - DK + * - Dominican Republic + - DO + * - Ecuador + - EC + * - Egypt + - EG + * - El Salvador + - SV + * - Estonia + - EE + * - Finland + - FI + * - France + - FR + * - Georgia + - GE + * - Germany + - DE + * - Ghana + - GH + * - Greece + - GR + * - Guatemala + - GT + * - Honduras + - HN + * - Hong Kong + - HK + * - Hungary + - HU + * - Iceland + - IS + * - India + - IN + * - Indonesia + - ID + * - Iraq + - IQ + * - Ireland + - IE + * - Israel + - IL + * - Italy + - IT + * - Jamaica + - JM + * - Japan + - JP + * - Jordan + - JO + * - Kazakhstan + - KZ + * - Kenya + - KE + * - Kuwait + - KW + * - Laos + - LA + * - Latvia + - LV + * - Lebanon + - LB + * - Libya + - LY + * - Liechtenstein + - LI + * - Lithuania + - LT + * - Luxembourg + - LU + * - Malaysia + - MY + * - Malta + - MT + * - Mexico + - MX + * - Montenegro + - ME + * - Morocco + - MA + * - Nepal + - NP + * - Netherlands + - NL + * - New Zealand + - NZ + * - Nicaragua + - NI + * - Nigeria + - NG + * - North Macedonia + - MK + * - Norway + - NO + * - Oman + - OM + * - Pakistan + - PK + * - Panama + - PA + * - Papua New Guinea + - PG + * - Paraguay + - PY + * - Peru + - PE + * - Philippines + - PH + * - Poland + - PL + * - Portugal + - PT + * - Puerto Rico + - PR + * - Qatar + - QA + * - Romania + - RO + * - Russia + - RU + * - Saudi Arabia + - SA + * - Senegal + - SN + * - Serbia + - RS + * - Singapore + - SG + * - Slovakia + - SK + * - Slovenia + - SI + * - South Africa + - ZA + * - South Korea + - KR + * - Spain + - ES + * - Sri Lanka + - LK + * - Sweden + - SE + * - Switzerland + - CH + * - Taiwan + - TW + * - Tanzania + - TZ + * - Thailand + - TH + * - Tunisia + - TN + * - Turkey + - TR + * - Uganda + - UG + * - Ukraine + - UA + * - United Arab Emirates + - AE + * - United Kingdom + - GB + * - United States + - US + * - Uruguay + - UY + * - Venezuela + - VE + * - Vietnam + - VN + * - Yemen + - YE + * - Zimbabwe + - ZW + +.. raw:: html + +
+ diff --git a/ytmusicapi/constants.py b/ytmusicapi/constants.py index f5cca615..86d8eeb0 100644 --- a/ytmusicapi/constants.py +++ b/ytmusicapi/constants.py @@ -3,6 +3,19 @@ YTM_PARAMS = '?alt=json' YTM_PARAMS_KEY = "&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0' +SUPPORTED_LANGUAGES = { + 'ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'ko', 'nl', 'pt', 'ru', 'tr', 'ur', 'zh_CN', + 'zh_TW' +} +SUPPORTED_LOCATIONS = { + 'AE', 'AR', 'AT', 'AU', 'AZ', 'BA', 'BD', 'BE', 'BG', 'BH', 'BO', 'BR', 'BY', 'CA', 'CH', 'CL', + 'CO', 'CR', 'CY', 'CZ', 'DE', 'DK', 'DO', 'DZ', 'EC', 'EE', 'EG', 'ES', 'FI', 'FR', 'GB', 'GE', + 'GH', 'GR', 'GT', 'HK', 'HN', 'HR', 'HU', 'ID', 'IE', 'IL', 'IN', 'IQ', 'IS', 'IT', 'JM', 'JO', + 'JP', 'KE', 'KH', 'KR', 'KW', 'KZ', 'LA', 'LB', 'LI', 'LK', 'LT', 'LU', 'LV', 'LY', 'MA', 'ME', + 'MK', 'MT', 'MX', 'MY', 'NG', 'NI', 'NL', 'NO', 'NP', 'NZ', 'OM', 'PA', 'PE', 'PG', 'PH', 'PK', + 'PL', 'PR', 'PT', 'PY', 'QA', 'RO', 'RS', 'RU', 'SA', 'SE', 'SG', 'SI', 'SK', 'SN', 'SV', 'TH', + 'TN', 'TR', 'TW', 'TZ', 'UA', 'UG', 'US', 'UY', 'VE', 'VN', 'YE', 'ZA', 'ZW' +} OAUTH_CLIENT_ID = "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com" OAUTH_CLIENT_SECRET = "SboVhoG9s0rNafixCSGGKXAT" OAUTH_SCOPE = "https://www.googleapis.com/auth/youtube" diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 8041a0b6..6f110b25 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -30,7 +30,8 @@ def __init__(self, user: str = None, requests_session=True, proxies: dict = None, - language: str = 'en'): + language: str = 'en', + location: str = ''): """ Create a new instance to interact with YouTube Music. @@ -62,6 +63,9 @@ def __init__(self, :param language: Optional. Can be used to change the language of returned data. English will be used by default. Available languages can be checked in the ytmusicapi/locales directory. + :param location: Optional. Can be used to change the location of the user. + No location will be set by default. This means it is determined by the server. + Available languages can be checked in the FAQ. """ self.auth = auth @@ -84,18 +88,24 @@ def __init__(self, # prepare context self.context = initialize_context() - self.context['context']['client']['hl'] = language - locale_dir = os.path.abspath(os.path.dirname(__file__)) + os.sep + 'locales' - supported_languages = [f for f in next(os.walk(locale_dir))[1]] - if language not in supported_languages: + + if location: + if location not in SUPPORTED_LOCATIONS: + raise Exception("Location not supported. Check the FAQ for supported locations.") + self.context['context']['client']['gl'] = location + + if language not in SUPPORTED_LANGUAGES: raise Exception("Language not supported. Supported languages are " - + (', '.join(supported_languages)) + ".") + + (', '.join(SUPPORTED_LANGUAGES)) + ".") + self.context['context']['client']['hl'] = language self.language = language try: locale.setlocale(locale.LC_ALL, self.language) except locale.Error: with suppress(locale.Error): locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') + + locale_dir = os.path.abspath(os.path.dirname(__file__)) + os.sep + 'locales' self.lang = gettext.translation('base', localedir=locale_dir, languages=[language]) self.parser = Parser(self.lang) From 89c6a2515fcaf83bb8851cdc287225dd28039379 Mon Sep 17 00:00:00 2001 From: Marvin Schenkel Date: Thu, 15 Jun 2023 20:14:03 +0200 Subject: [PATCH 156/238] Add external OAuth case to browser check (#396) --- ytmusicapi/auth/headers.py | 5 ++++- ytmusicapi/auth/oauth.py | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/ytmusicapi/auth/headers.py b/ytmusicapi/auth/headers.py index 981dd0b7..9c0da74c 100644 --- a/ytmusicapi/auth/headers.py +++ b/ytmusicapi/auth/headers.py @@ -6,7 +6,7 @@ from requests.structures import CaseInsensitiveDict from ytmusicapi.auth.browser import is_browser -from ytmusicapi.auth.oauth import YTMusicOAuth, is_oauth +from ytmusicapi.auth.oauth import YTMusicOAuth, is_oauth, is_custom_oauth from ytmusicapi.helpers import initialize_headers @@ -36,6 +36,9 @@ def prepare_headers( elif is_browser(input_dict): headers = input_dict + elif is_custom_oauth(input_dict): + headers = input_dict + else: raise Exception( "Could not detect credential type. " diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index 18457457..6a06e480 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -22,6 +22,11 @@ def is_oauth(headers: CaseInsensitiveDict) -> bool: return all(key in headers for key in oauth_structure) +def is_custom_oauth(headers: CaseInsensitiveDict) -> bool: + """Checks whether the headers contain a Bearer token, indicating a custom OAuth implementation.""" + return "authorization" in headers and headers["authorization"].startswith("Bearer ") + + class YTMusicOAuth: """OAuth implementation for YouTube Music based on YouTube TV""" From 6b759397992a8e4e77dd99cbf527e9a29c0e8e19 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 15 Jun 2023 20:15:35 +0200 Subject: [PATCH 157/238] fix test --- tests/test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test.py b/tests/test.py index 35b16eea..8525de1a 100644 --- a/tests/test.py +++ b/tests/test.py @@ -162,8 +162,8 @@ def test_get_artist_albums(self): self.assertGreater(len(results), 0) def test_get_artist_singles(self): - artist = self.yt_auth.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") - results = self.yt_auth.get_artist_albums(artist["singles"]["browseId"], + artist = self.yt.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") + results = self.yt.get_artist_albums(artist["singles"]["browseId"], artist["singles"]["params"]) self.assertGreater(len(results), 0) From 7c9455e42c7589af3d2466da92a26133da752bdc Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 15 Jun 2023 20:20:16 +0200 Subject: [PATCH 158/238] test location parameter --- tests/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test.py b/tests/test.py index 8525de1a..6a5929e2 100644 --- a/tests/test.py +++ b/tests/test.py @@ -37,7 +37,7 @@ def setUpClass(cls): assert isinstance(yt, YTMusic) cls.yt = YTMusic() cls.yt_oauth = YTMusic(headers_oauth) - cls.yt_auth = YTMusic(headers_browser) + cls.yt_auth = YTMusic(headers_browser, location="GB") cls.yt_brand = YTMusic(config["auth"]["headers"], config["auth"]["brand_account"]) cls.yt_empty = YTMusic(config["auth"]["headers_empty"], config["auth"]["brand_account_empty"]) From e26335e6649ca9b5e18ecd64ce7fa7a2f2b7e081 Mon Sep 17 00:00:00 2001 From: Marvin Schenkel Date: Sun, 25 Jun 2023 20:28:26 +0200 Subject: [PATCH 159/238] Fix 'secondSubtitle' KeyError for some playlists (#399) * Fix 'secondSubtitle' KeyError for some playlists * Feedback. --- ytmusicapi/mixins/playlists.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 70f0dfef..1dfebd64 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -132,14 +132,16 @@ def get_playlist(self, if run_count == 5: playlist['year'] = nav(header, SUBTITLE3) - 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['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 = len(results['contents']) playlist['trackCount'] = song_count request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) From 94f15138229cd6bca80a8968eae3ec14e1e2baee Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 2 Jul 2023 22:15:38 +0200 Subject: [PATCH 160/238] fix empty liked songs crash caused by #399 --- tests/test.py | 7 +++++-- ytmusicapi/mixins/playlists.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/test.py b/tests/test.py index 6a5929e2..0180d2c0 100644 --- a/tests/test.py +++ b/tests/test.py @@ -388,20 +388,23 @@ def test_subscribe_artists(self): # PLAYLISTS ############### - def test_get_foreign_playlist(self): + def test_get_playlist_foreign(self): self.assertRaises(Exception, self.yt.get_playlist, "PLABC") playlist = self.yt.get_playlist(sample_playlist, limit=300, suggestions_limit=7) self.assertGreater(len(playlist['duration']), 5) self.assertGreater(len(playlist["tracks"]), 200) self.assertNotIn("suggestions", playlist) + self.yt.get_playlist("RDATgXd-") + self.assertGreaterEqual(len(playlist["tracks"]), 100) + playlist = self.yt_oauth.get_playlist("PLj4BSJLnVpNyIjbCWXWNAmybc97FXLlTk", limit=None, related=True) self.assertGreater(len(playlist["tracks"]), 200) self.assertEqual(len(playlist["related"]), 0) - def test_get_owned_playlist(self): + def test_get_playlist_owned(self): playlist = self.yt_brand.get_playlist(config["playlists"]["own"], related=True, suggestions_limit=21) diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 1dfebd64..1e6f8820 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -140,8 +140,11 @@ def get_playlist(self, 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 = len(results['contents']) + 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 request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) From ef312d75ed6ebdf32f99ff3c2d561e8610af468d Mon Sep 17 00:00:00 2001 From: zeitmeister <31163618+zeitmeister@users.noreply.github.com> Date: Mon, 3 Jul 2023 21:14:03 +0200 Subject: [PATCH 161/238] Oauth refresh on request (#403) * updating token on every request if auth is oauth * remove print line * headers only updated if auth is oauth * some refactoring * dont know what happened here * check for self.auth --------- Co-authored-by: simon --- ytmusicapi/auth/headers.py | 8 +++----- ytmusicapi/ytmusic.py | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/ytmusicapi/auth/headers.py b/ytmusicapi/auth/headers.py index 9c0da74c..08279504 100644 --- a/ytmusicapi/auth/headers.py +++ b/ytmusicapi/auth/headers.py @@ -23,15 +23,13 @@ def load_headers_file(auth: str) -> Dict: def prepare_headers( session: requests.Session, proxies: Optional[Dict] = None, - auth: Optional[str] = None, + input_dict: Optional[CaseInsensitiveDict] = None, ) -> Dict: - if auth: - input_json = load_headers_file(auth) - input_dict = CaseInsensitiveDict(input_json) + if input_dict: if is_oauth(input_dict): oauth = YTMusicOAuth(session, proxies) - headers = oauth.load_headers(dict(input_dict), auth) + headers = oauth.load_headers(dict(input_dict), input_dict['filepath']) elif is_browser(input_dict): headers = input_dict diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 6f110b25..f63ad351 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -5,7 +5,8 @@ from contextlib import suppress from typing import Dict -from ytmusicapi.auth.headers import prepare_headers +from requests.structures import CaseInsensitiveDict +from ytmusicapi.auth.headers import load_headers_file, prepare_headers from ytmusicapi.parsers.i18n import Parser from ytmusicapi.helpers import * from ytmusicapi.mixins.browsing import BrowsingMixin @@ -15,6 +16,7 @@ from ytmusicapi.mixins.library import LibraryMixin from ytmusicapi.mixins.playlists import PlaylistsMixin from ytmusicapi.mixins.uploads import UploadsMixin +from ytmusicapi.auth.oauth import YTMusicOAuth, is_oauth class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, @@ -68,6 +70,8 @@ def __init__(self, Available languages can be checked in the FAQ. """ self.auth = auth + self.input_dict = None + self.is_oauth_auth = False if isinstance(requests_session, requests.Session): self._session = requests_session @@ -80,8 +84,13 @@ def __init__(self, self.proxies = proxies self.cookies = {'CONSENT': 'YES+1'} + if self.auth is not None: + input_json = load_headers_file(self.auth) + self.input_dict = CaseInsensitiveDict(input_json) + self.input_dict['filepath'] = self.auth + self.is_oauth_auth = is_oauth(self.input_dict) - self.headers = prepare_headers(self._session, proxies, auth) + self.headers = prepare_headers(self._session, proxies, self.input_dict) if 'x-goog-visitor-id' not in self.headers: self.headers.update(get_visitor_id(self._send_get_request)) @@ -121,7 +130,12 @@ def __init__(self, except KeyError: raise Exception("Your cookie is missing the required value __Secure-3PAPISID") + + def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") -> Dict: + + if self.is_oauth_auth: + self.headers = prepare_headers(self._session, self.proxies, self.input_dict) body.update(self.context) params = YTM_PARAMS if self.is_browser_auth: From bff2b667da1d7c2f59b34e12ca9db672228fd118 Mon Sep 17 00:00:00 2001 From: nick42d <133559267+nick42d@users.noreply.github.com> Date: Wed, 19 Jul 2023 23:34:41 +0800 Subject: [PATCH 162/238] Removed redundant loop counter The count in parse_playlist_items is not referenced elsewhere in the function. --- ytmusicapi/parsers/playlists.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index 3552bb83..4fdecb97 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -5,9 +5,7 @@ def parse_playlist_items(results, menu_entries: List[List] = None): songs = [] - count = 1 for result in results: - count += 1 if MRLIR not in result: continue data = result[MRLIR] From 3bf7b49eab96d05f97297efce1f0a74845ccaa61 Mon Sep 17 00:00:00 2001 From: nick42d <133559267+nick42d@users.noreply.github.com> Date: Sat, 5 Aug 2023 18:23:23 +0800 Subject: [PATCH 163/238] Fix for KeyError when searching community_playlists or featured_playlists. Closes #420 (#421) * Fix for issue #420 KeyError when searching community_playlists or featured_playlists with scope library * From PR feedback - better exception message for search guard clause. * Fixed lint --- tests/test.py | 14 +++++++++++++- ytmusicapi/mixins/search.py | 9 ++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/test.py b/tests/test.py index 0180d2c0..2fb65390 100644 --- a/tests/test.py +++ b/tests/test.py @@ -137,6 +137,18 @@ def test_search_library(self): self.assertGreaterEqual(len(results), 1) results = self.yt_auth.search("garrix", filter="playlists", scope="library") self.assertGreaterEqual(len(results), 1) + self.assertRaises(Exception, + self.yt_auth.search, + "beatles", + filter="community_playlists", + scope="library", + limit=40) + self.assertRaises(Exception, + self.yt_auth.search, + "beatles", + filter="featured_playlists", + scope="library", + limit=40) def test_get_artist(self): results = self.yt.get_artist("MPLAUCmMUZbaYdNH0bEd1PAlAqsA") @@ -164,7 +176,7 @@ def test_get_artist_albums(self): def test_get_artist_singles(self): artist = self.yt.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") results = self.yt.get_artist_albums(artist["singles"]["browseId"], - artist["singles"]["params"]) + artist["singles"]["params"]) self.assertGreater(len(results), 0) def test_get_user(self): diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 8a3fe197..d22cdb65 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -19,8 +19,10 @@ def search(self, :param filter: Filter for item types. Allowed values: ``songs``, ``videos``, ``albums``, ``artists``, ``playlists``, ``community_playlists``, ``featured_playlists``, ``uploads``. Default: Default search, including all types of items. :param scope: Search scope. Allowed values: ``library``, ``uploads``. - For uploads, no filter can be set! An exception will be thrown if you attempt to do so. Default: Search the public YouTube Music catalogue. + Changing scope from the default will reduce the number of settable filters. Setting a filter that is not permitted will throw an exception. + For uploads, no filter can be set. + For library, community_playlists and featured_playlists filter cannot be set. :param limit: Number of search results to return Default: 20 :param ignore_spelling: Whether to ignore YTM spelling suggestions. @@ -143,6 +145,11 @@ def search(self, "No filter can be set when searching uploads. Please unset the filter parameter when scope is set to " "uploads. ") + if scope == scopes[0] and filter in filters[3:5]: + raise Exception(f"{filter} cannot be set when searching library. " + f"Please use one of the following filters or leave out the parameter: " + + ', '.join(filters[0:3]+filters[5:])) + params = get_search_params(filter, scope, ignore_spelling) if params: body['params'] = params From 3770b4516cf0dc32dba9455a27c95216fe313307 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Fri, 11 Aug 2023 21:07:43 +0200 Subject: [PATCH 164/238] fix test --- tests/test.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test.py b/tests/test.py index 2fb65390..5d1f8b73 100644 --- a/tests/test.py +++ b/tests/test.py @@ -470,12 +470,7 @@ def test_end2end(self): self.assertEqual(len(playlist["tracks"]), 46, "Getting playlist items failed") response = self.yt_brand.remove_playlist_items(playlistId, playlist["tracks"]) self.assertEqual(response, "STATUS_SUCCEEDED", "Playlist item removal failed") - response = self.yt_brand.delete_playlist(playlistId) - self.assertEqual( - response["command"]["handlePlaylistDeletionCommand"]["playlistId"], - playlistId, - "Playlist removal failed", - ) + self.yt_brand.delete_playlist(playlistId) ############### # UPLOADS From 2be108e2b6d5f43b12304881dd1d010f0b3316e7 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 20 Aug 2023 17:18:35 +0200 Subject: [PATCH 165/238] search: support profiles (closes #425) --- tests/test.py | 25 +++++++++---- ytmusicapi/locales/ar/LC_MESSAGES/base.mo | Bin 751 -> 799 bytes ytmusicapi/locales/ar/LC_MESSAGES/base.po | 30 +++++++++------- ytmusicapi/locales/base.pot | 34 ++++++++++-------- ytmusicapi/locales/de/LC_MESSAGES/base.mo | Bin 728 -> 759 bytes ytmusicapi/locales/de/LC_MESSAGES/base.po | 28 ++++++++------- ytmusicapi/locales/en/LC_MESSAGES/base.po | 28 ++++++++------- ytmusicapi/locales/es/LC_MESSAGES/base.mo | Bin 762 -> 793 bytes ytmusicapi/locales/es/LC_MESSAGES/base.po | 28 ++++++++------- ytmusicapi/locales/fr/LC_MESSAGES/base.mo | Bin 605 -> 644 bytes ytmusicapi/locales/fr/LC_MESSAGES/base.po | 28 ++++++++------- ytmusicapi/locales/hi/LC_MESSAGES/base.mo | Bin 786 -> 838 bytes ytmusicapi/locales/hi/LC_MESSAGES/base.po | 30 +++++++++------- ytmusicapi/locales/it/LC_MESSAGES/base.mo | Bin 647 -> 679 bytes ytmusicapi/locales/it/LC_MESSAGES/base.po | 28 ++++++++------- ytmusicapi/locales/ja/LC_MESSAGES/base.mo | Bin 782 -> 825 bytes ytmusicapi/locales/ja/LC_MESSAGES/base.po | 28 ++++++++------- ytmusicapi/locales/ko/LC_MESSAGES/base.mo | Bin 742 -> 776 bytes ytmusicapi/locales/ko/LC_MESSAGES/base.po | 32 +++++++++-------- ytmusicapi/locales/nl/LC_MESSAGES/base.mo | Bin 771 -> 803 bytes ytmusicapi/locales/nl/LC_MESSAGES/base.po | 30 +++++++++------- ytmusicapi/locales/pt/LC_MESSAGES/base.mo | Bin 712 -> 743 bytes ytmusicapi/locales/pt/LC_MESSAGES/base.po | 30 +++++++++------- ytmusicapi/locales/ru/LC_MESSAGES/base.mo | Bin 749 -> 788 bytes ytmusicapi/locales/ru/LC_MESSAGES/base.po | 30 +++++++++------- ytmusicapi/locales/tr/LC_MESSAGES/base.mo | Bin 699 -> 730 bytes ytmusicapi/locales/tr/LC_MESSAGES/base.po | 30 +++++++++------- ytmusicapi/locales/update_po.sh | 2 +- ytmusicapi/locales/ur/LC_MESSAGES/base.mo | Bin 718 -> 755 bytes ytmusicapi/locales/ur/LC_MESSAGES/base.po | 30 +++++++++------- .../zh_CN/LC_MESSAGES/{base.pot => base.po} | 31 ++++++++-------- ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po | 30 +++++++++------- ytmusicapi/mixins/search.py | 12 +++++-- ytmusicapi/parsers/i18n.py | 2 +- ytmusicapi/parsers/search.py | 26 +++++++++----- 35 files changed, 334 insertions(+), 238 deletions(-) rename ytmusicapi/locales/zh_CN/LC_MESSAGES/{base.pot => base.po} (69%) diff --git a/tests/test.py b/tests/test.py index 5d1f8b73..3304804f 100644 --- a/tests/test.py +++ b/tests/test.py @@ -93,26 +93,37 @@ def test_search(self): self.assertGreater(len(results), 10) results = self.yt_auth.search("Martin Stig Andersen - Deteriation", ignore_spelling=True) self.assertGreater(len(results), 0) + + def test_search_filters(self): + query = "hip hop playlist" results = self.yt_auth.search(query, filter="songs") - self.assertListEqual([r["resultType"] for r in results], ["song"] * len(results)) self.assertGreater(len(results), 10) + self.assertTrue(all(item['resultType'] == 'song' for item in results)) results = self.yt_auth.search(query, filter="videos") self.assertGreater(len(results), 10) + self.assertTrue(all(item['resultType'] == 'video' for item in results)) results = self.yt_auth.search(query, filter="albums", limit=40) - self.assertListEqual([r["resultType"] for r in results], ["album"] * len(results)) self.assertGreater(len(results), 20) + self.assertTrue(all(item['resultType'] == 'album' for item in results)) results = self.yt_auth.search("project-2", filter="artists", ignore_spelling=True) - self.assertGreater(len(results), 0) + self.assertGreater(len(results), 10) + self.assertTrue(all(item['resultType'] == 'artist' for item in results)) results = self.yt_auth.search("classical music", filter="playlists") - self.assertGreater(len(results), 5) + self.assertGreater(len(results), 10) + self.assertTrue(all(item['resultType'] == 'playlist' for item in results)) results = self.yt_auth.search("clasical music", filter="playlists", ignore_spelling=True) - self.assertGreater(len(results), 5) + self.assertGreater(len(results), 10) results = self.yt_auth.search("clasic rock", filter="community_playlists", ignore_spelling=True) - self.assertGreater(len(results), 5) + self.assertGreater(len(results), 10) + self.assertTrue(all(item['resultType'] == 'playlist' for item in results)) results = self.yt_auth.search("hip hop", filter="featured_playlists") - self.assertGreater(len(results), 5) + self.assertGreater(len(results), 10) + self.assertTrue(all(item['resultType'] == 'playlist' for item in results)) + results = self.yt_auth.search("some user", filter="profiles") + self.assertGreater(len(results), 10) + self.assertTrue(all(item['resultType'] == 'profile' for item in results)) def test_search_uploads(self): self.assertRaises( diff --git a/ytmusicapi/locales/ar/LC_MESSAGES/base.mo b/ytmusicapi/locales/ar/LC_MESSAGES/base.mo index b750b12bba71d0fa6b54214fb4c8ffc092ded1bb..4db42cab951a5eba9bb76f571d5e7651b595688a 100644 GIT binary patch delta 332 zcmXZWKWoBZ5XbQ+)~K;oQ1DODA>DM6ZqANwI=VQuMNll_rVs?XxCY1S*sY6-FT*tf z>kDvkl5XPnlIP&~-0ulo?qaTE&M6yRDk?>n=_#F|kF=C+C>6sOZ3PqL8d_M#I5xCR zZ5yM!cXaNeN&Z0DH^6_T95rT0^U#lkiziql&+!?*v4DGg!UM{&f0)EG%0XA1Z6vpv$=k9zLCkPt|R;@;(MX;>QX0wXPzT_tu=6wQ_&2AI1TKoz|tN(Mo z2OfTV*yVDzD*D*sJqS<2w8@AZNKEcTr*A|ejJ*jq=woc+1nW5S&M~Afd|#raXQ=nA z@rxVmi4>9t{|mR6@M4E49pt66{y>?U>1Ad1rhEq34}%aCRR910 diff --git a/ytmusicapi/locales/ar/LC_MESSAGES/base.po b/ytmusicapi/locales/ar/LC_MESSAGES/base.po index 7fe65563..d1755d2b 100644 --- a/ytmusicapi/locales/ar/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/ar/LC_MESSAGES/base.po @@ -1,13 +1,13 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: 2023-01-02 22:14+0530\n" "Last-Translator: \n" "Language-Team: \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.2.2\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "فنان" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "قائمةالتشغيل" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "أغنية" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "فيديو" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "محطة" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "الملف الشخصي" + +#: parsers/i18n.py:21 msgid "albums" msgstr "ألبومات" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "الفردي" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "أشرطة فيديو" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "قوائم التشغيل" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "ذات صلة" diff --git a/ytmusicapi/locales/base.pot b/ytmusicapi/locales/base.pot index 9b1dfc2f..87a762d6 100644 --- a/ytmusicapi/locales/base.pot +++ b/ytmusicapi/locales/base.pot @@ -1,14 +1,14 @@ -# Translations for ytmusicapi -# Copyright (C) 2021 sigma67 -# This file is distributed under the same license as ytmusicapi -# sigma67 +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "" + +#: parsers/i18n.py:21 msgid "albums" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "" diff --git a/ytmusicapi/locales/de/LC_MESSAGES/base.mo b/ytmusicapi/locales/de/LC_MESSAGES/base.mo index 11bb509fa4940a5b008a57d6dde3fefd3f670c2a..6f298ba2fddccba62ac95aef907d2ba55ff70eae 100644 GIT binary patch delta 316 zcmXYru?_)I5Jlf(*IKrrQ4pb{5}jJ9RBBXKB!q}kvOl1*q0*`q5`Q95pwX#xT4(G` za&zXs$-6T*_v=Q_qO&tpfGTK>3h0icYSx$xtScKZi*G?6cAy8l%AT?h9o`4(htS23 zq2$eA$(qQlbg)S!fm z^)=~q+n@Wm;@giehIHwO<}{@*s`RWeO_&B|u!T=x8%|*Y=Yb0t9kKoVXZ^)((m;P`asKaQtK@~l#Xw&)6_R1e9 Ohjw<`*n=&rX!!?E`V*c2 diff --git a/ytmusicapi/locales/de/LC_MESSAGES/base.po b/ytmusicapi/locales/de/LC_MESSAGES/base.po index c604e2d8..c9f119d1 100644 --- a/ytmusicapi/locales/de/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/de/LC_MESSAGES/base.po @@ -1,5 +1,5 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "künstler" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "playlist" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "titel" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "video" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "sender" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "profil" + +#: parsers/i18n.py:21 msgid "albums" msgstr "alben" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "singles" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "videos" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "playlists" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "das könnte fans auch gefallen" diff --git a/ytmusicapi/locales/en/LC_MESSAGES/base.po b/ytmusicapi/locales/en/LC_MESSAGES/base.po index 63ea9544..20d5b9bd 100644 --- a/ytmusicapi/locales/en/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/en/LC_MESSAGES/base.po @@ -1,5 +1,5 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "" + +#: parsers/i18n.py:21 msgid "albums" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "fans might also like" diff --git a/ytmusicapi/locales/es/LC_MESSAGES/base.mo b/ytmusicapi/locales/es/LC_MESSAGES/base.mo index e103c16a8b9a79e585c9e7b6c1d55688600c927d..59d70f5229fc4ce6ca6fd4878c0807a16df935b7 100644 GIT binary patch delta 315 zcmXZWzYf7r6vy#eU$j(LD`jJ^vX6Dag=PIF`W2TYvf@!deHTogeafDTz1g99&&qBXIN54YN zSz|^vggJ%DqyK;{c6eZq8J@6@7u2E~YViZL@P!)q4*dt2>5HSk(?Gq9?p26NcT}3; U{Ac@?KT!7U_`I|`Ti&Dj4{nPTivR!s diff --git a/ytmusicapi/locales/es/LC_MESSAGES/base.po b/ytmusicapi/locales/es/LC_MESSAGES/base.po index a8969c6d..fd6bc80d 100644 --- a/ytmusicapi/locales/es/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/es/LC_MESSAGES/base.po @@ -1,5 +1,5 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "artista" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "lista de reproducción" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "canción" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "vídeo" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "emisora" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "perfil" + +#: parsers/i18n.py:21 msgid "albums" msgstr "álbumes" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "sencillos" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "vídeos" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "listas de reproducción" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "puede que también te guste" diff --git a/ytmusicapi/locales/fr/LC_MESSAGES/base.mo b/ytmusicapi/locales/fr/LC_MESSAGES/base.mo index 7ac8523fb5f5bd60627aaaa17d27f5b505c4877e..91eea02eacd225a6e93777c7c3a6dc782d4003ed 100644 GIT binary patch delta 275 zcmcc1(!yGQPl#nI0}yZmu?!HW05LBRuK{8ZcmTv~K>QGhIe_>D5VHgEM=1RhNV5Pj zBO^qd4M?*B`Fubcq)!A$ivzJV5QFp?GeOL;2GT(JdIlFDLl!9E4WvODGof@oL;*u7 zkOpb20n$J-zyQPt0g!r-qd*P-O2Yv&kOlG($N>zAMJ1WVB@6{QiIq8EMp1rRW=<+Y VQEEc0T7I3okYZy*g)FU$ndFAbz+fqWGp4OAb=-~?eXcmQdTMn518(hv%y wf%?G!B+d+FgDe3#1O$Lez>WZ^XGkn6$t*5mD9A~y%mFes{_tU(?9F5e07mK # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "artiste" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "playlist" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "titre" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "vidéo" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "radio" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "profil" + +#: parsers/i18n.py:21 msgid "albums" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "vidéos" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "vous aimerez peut-être aussi" diff --git a/ytmusicapi/locales/hi/LC_MESSAGES/base.mo b/ytmusicapi/locales/hi/LC_MESSAGES/base.mo index a133337cb3a039587a01022117da4a8a501025f7..f27e3ec655866ad2557c7a59d023f3496322914e 100644 GIT binary patch delta 335 zcmbQlc8sn5o)F7a1|Z-8Vi_Q=0b)TQz5~P{puot$zy_ohp|l2&<^b~bfix$OwgA%X zK-vjPdqL?SAk6|4kAm{!fix?SpAMu!=H&wE?+gs}3?)DY$nqQ}1_mJ@T?wS6fpia) zUIC?#0BH%J_yZsfviK*IW@d)ybu##%83l@pQkYF(2FeEIYlB8Q8H0%P$8bFbtF$gps3IE*8 zBqv`_)5o+ofAr-mi%-I|$$%6jC3m9Jbs`>9Gsgyfh)o<}9Vg}#WBSbc925Ey^*L+& z;s!e+rK}>6j1ybzV__aJr=L+@bTuES*FVjV`9q!=hQn}A3w0S?xJGnz2_0QJ37;J* Qf1v8Q@nzxmu6jqiKbWBv8vp -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: 2023-01-01 11:04+0530\n" "Last-Translator: \n" "Language-Team: \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.2.2\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "कलाकार" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "प्लेलिस्ट" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "गीत" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "वीडियो" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "स्टेशन" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "प्रोफ़ाइल" + +#: parsers/i18n.py:21 msgid "albums" msgstr "एलबम" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "एकल" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "वीडियो" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "प्लेलिस्ट" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "सम्बंधित" diff --git a/ytmusicapi/locales/it/LC_MESSAGES/base.mo b/ytmusicapi/locales/it/LC_MESSAGES/base.mo index e7275573dd04d919f3a5e57242859b4a997dadba..2b7bf739bd5109cfcddce8834eb5afe6779cc03a 100644 GIT binary patch delta 287 zcmZo?UCvs6Pl#nI0}yZlu?!IB05LBR?*U>E_yELgK>QI({{YgQK>Qzw*+FbZ2rUSu zC4n>xP+SqpR|C=@{dz!J9EeSUm=(zPWdiG~X9xx|IDi7t5CMi1AT0~z=Ro;gKpLcB z5|9R|n*rr71kymWzyRbRkfk61QUg-Y48%YsU;uIe$b%pUGbH9DmF5;RBo>uq7MDzX Z$IV_)l%JNFle+Oo2qTo8KiQkf1^`bY9-ROH delta 239 zcmZ3^+Rj>kPl#nI0}yZmu?!HW05LBRuK{8ZcmTv~K>QF&zW~x8weNwL9f*HIX+}l{ z1{NTj6G(%^`G7P?pBRu92Vz+uW(D$1nIQ7kK$?SrA&$Wr$dCm}_&_DHfHX*95kvt) u1(e?iq=9CD0Z=2@G7tyk7?6d`Knzp@1|a<)z&7!%%w|ExV8+QgOx6G!Vi8yX diff --git a/ytmusicapi/locales/it/LC_MESSAGES/base.po b/ytmusicapi/locales/it/LC_MESSAGES/base.po index efc64dad..117df805 100644 --- a/ytmusicapi/locales/it/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/it/LC_MESSAGES/base.po @@ -1,5 +1,5 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "artista" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "brano" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "stazione" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "profilo" + +#: parsers/i18n.py:21 msgid "albums" msgstr "album" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "singoli" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "video" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "playlist" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "ai fan potrebbe anche piacere" diff --git a/ytmusicapi/locales/ja/LC_MESSAGES/base.mo b/ytmusicapi/locales/ja/LC_MESSAGES/base.mo index a62e4328574e4b38837bd523e14f9dba9c8058c8..497f7e8396f4a0863efbe35625eca526bb0bd55a 100644 GIT binary patch delta 327 zcmeBU+sRgcPl#nI0}${4u?!H`0I?tt-vMF}P+(+WU<1;MP+9{>a{&4JK$;UsTL5Wx zAngRDy`XdukY)jjM?v}VK$;cEPY2Q<^KyZ-I3q(nLkW-pvb=|hfk6mJ&xF!zp!9wy zeF;b_0_C3qX^?t$W(EczVh{lmAbk=*8f36Glr{p=K%>C`s0A#?4WvPi0y!Kg4Fu8< z*MpcKUx8c*0$f1MGntc7lf9rQKP@vSb+aL37o%YF^vBI>A2&~Z+_d;{^Pb1et0(Vd G3Izb@y&=i~ delta 285 zcmXZXF$%&!5QX8{m~7Nom^3N~VyCU8rHx=?A?Z9u`e12e6Fq|mu+lSl1hEt>yo3KN zGw}GB-Rv;A`;)KVMSKw|BV$sLoIHtE--&q4%^|kwC)mLmrnoSd7}Kw;Ut>bQMV(XP zfb2<6q?RfYDY!V8XY-0v&L7B!^1>E=?f!>4P!H7k{t( diff --git a/ytmusicapi/locales/ja/LC_MESSAGES/base.po b/ytmusicapi/locales/ja/LC_MESSAGES/base.po index 9e9ae68c..8ea993d7 100644 --- a/ytmusicapi/locales/ja/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/ja/LC_MESSAGES/base.po @@ -1,5 +1,5 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 # @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "アーティスト" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "プレイリスト" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "曲" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "動画" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "ステーション" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "プロフィール" + +#: parsers/i18n.py:21 msgid "albums" msgstr "アルバム" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "シングル" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "動画" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "プレイリスト" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "おすすめのアーティスト" diff --git a/ytmusicapi/locales/ko/LC_MESSAGES/base.mo b/ytmusicapi/locales/ko/LC_MESSAGES/base.mo index c77275b4eda3ea24940b9e43ab3242e4b6a8c683..d3a7560fbf2c94833cd9cd4ed8f64e70de6e821b 100644 GIT binary patch delta 319 zcmaFH+QC+TPl#nI0}${4u?!H`0I?tt-vMF}P+(+WU<1;MP+9{>a{&4JK$;UsTL5Wx zAngRDy`XdukY)jjM?v}VK$;cEPY2Q<^KyZ-I3q(nLkU!%hY8|{$xwO$lwJ#@K^E-- z(jq|m2#^LDa1%;D0Ma1GKL^qvbzh+LKOhY>8VrC;upVw84RRDn9WxL!Fi1mO4`PCR s1#%$>Z~-yTWKKp+_JX4Pw9K5;&4!GHjGS+$w7gz8=k3&%$qSiW0foUI<^TWy delta 285 zcmXZYEe^s!5QgE|(k;J&5)vRG!Gc912?+@XRmUL^b%@&frWfD}2pkF=3QdrlgLjsh zfVNAcaev6)dk2)vA z5jp((Lh?wY$HB=g%^HWiKcil}nm0Rtpf2z--^k2RJchFl>N2`;gJ|h2EnV7&&kikr PpqjYFW#^8rx<{KI{hSk& diff --git a/ytmusicapi/locales/ko/LC_MESSAGES/base.po b/ytmusicapi/locales/ko/LC_MESSAGES/base.po index 857bd77b..75e3afa9 100644 --- a/ytmusicapi/locales/ko/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/ko/LC_MESSAGES/base.po @@ -1,14 +1,14 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "아티스트" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "재생목록" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "노래" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "동영상" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "스테이션" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "프로필" + +#: parsers/i18n.py:21 msgid "albums" msgstr "앨범" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "싱글" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "동영상" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "재싱목록" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" -msgstr "fans might also like" \ No newline at end of file +msgstr "fans might also like" diff --git a/ytmusicapi/locales/nl/LC_MESSAGES/base.mo b/ytmusicapi/locales/nl/LC_MESSAGES/base.mo index 1da550926f4560175f871ad523564bf005d3dd4e..b6620ebbaff0dbbb3ee8360e7ed00f0df5e696c4 100644 GIT binary patch delta 317 zcmZo>Tg+B}Pl#nI0}${4u?!H`0I?tt-vMF}P+(+WU<1;MP+9{>a{&4JK$;UsTL5Wx zAngRDy`XdukY)jjM?v}VK$;cEPY2Q<^KyYSP;EU!2~=P|6U3lnKpNzjb3mF8NZ*Ev zzXHjBv{~wSB8N|j6(Z>sL2&5sJK}?XZ lKn8#S7ZCGI=48}lFDS}S%gjmLY{i z2ZnElU1sOx_AW2`@xh=r9^oBM@dHaeYm9?w-~cwrW7vcfScmh#1&qndkXJAvuc3N2 zFvHs_=jI$$1sB-i!3}od9ct7w+?P-VuaMtR#ov&BkWMd-eq0M`j0V@Rqz*}=6aRyc Qd{zwY^qSeDEk4og4~;PuU;qFB diff --git a/ytmusicapi/locales/nl/LC_MESSAGES/base.po b/ytmusicapi/locales/nl/LC_MESSAGES/base.po index abd94495..0d09b679 100644 --- a/ytmusicapi/locales/nl/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/nl/LC_MESSAGES/base.po @@ -1,13 +1,13 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: 2023-04-23 14:55+0200\n" "Last-Translator: Heimen Stoffels \n" "Language-Team: \n" @@ -18,42 +18,46 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 3.2.2\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "artiest" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "afspeellijst" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "nummer" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "video" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "station" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "profiel" + +#: parsers/i18n.py:21 msgid "albums" msgstr "albums" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "singles" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "video's" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "afspeellijsten" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "gerelateerd" diff --git a/ytmusicapi/locales/pt/LC_MESSAGES/base.mo b/ytmusicapi/locales/pt/LC_MESSAGES/base.mo index 65af57fa55a919ff9e197b1d691bec0884aa2a4e..d3aa31ffe4f929e14ecaf8653a94615dd09d24ca 100644 GIT binary patch delta 316 zcmXZXF%Q8&6u|MjYD=+@urRPOiOp;@8Z9g(5~683HtAOojSpdySj}QG8-&qEu=>CD zUh?w0cYXKndUw7~@~+!QMO4W)8Iv{gMM`wEQZ9ClJuFcV(8CcHacrCzr)aZ3GxZ!D z>J`eoE&h~BRAd@9Rz4`g1`RPb@qlvRgcUraTzo~j_-5({O5fA$e~{5xu*|;6d3lta oFOW*L@*%R6zmgY9l*wx9>3%T?cgyw0zYO(4yMZ54q(BWP%U~$@9Bho)2g;Lriw|hk9G108#u-qPJ?rd$%~Me=*Vl- zoD_eUmFKzHMCE`CyBzGWhX>T7j;KXv)S^qsSJc2q`2R#^`r^~yX`&vZ2Ulp7?x-}& T`3*j@tQgqIePy?{xJ9!+TUZl# diff --git a/ytmusicapi/locales/pt/LC_MESSAGES/base.po b/ytmusicapi/locales/pt/LC_MESSAGES/base.po index 3ec9d22a..0be81fe4 100644 --- a/ytmusicapi/locales/pt/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/pt/LC_MESSAGES/base.po @@ -1,13 +1,13 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: 2023-01-01 12:15+0530\n" "Last-Translator: \n" "Language-Team: \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.2.2\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "artista" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "lista de reprodução" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "música" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "vídeo" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "estação" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "perfil" + +#: parsers/i18n.py:21 msgid "albums" msgstr "álbuns" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "solteiros" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "vídeos" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "listas de reprodução" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "relacionado" diff --git a/ytmusicapi/locales/ru/LC_MESSAGES/base.mo b/ytmusicapi/locales/ru/LC_MESSAGES/base.mo index 6134e72dc903681292a83bddd79f82b0e5dee2c8..db8dd86a084e057a575275b530ce2236dc6bd59f 100644 GIT binary patch delta 324 zcmXZXy$gYH5Ww+!ygl9$Pnj6lm}D~>jRu3ogcOl%6ob)*O#TODQHp=zc?O#dcC*Fc zd;G4h&+qQ)o_k)wO=$jb0$U;CWR{$fDe_Kgb%7 delta 285 zcmXZYJqp4=5QgE|n2o=P2?z>;t+tkyHiC_f>FfkgkUo_sh=QlEv9z$Wvb7a2U~TI= z%M1)pzD=^3%`y4<@}0#eq1yC_F6fkg(po(yl3;2Mut6SU6DL^5nK{Rpys*4PPhO+$ zlj9G!*cB;d6Y&uic4mPY`G9>qV+SwT!W;6UJS@NL{s-AJ6qjK*eO`yrforsu4xyz( U`*7?K`2*F^O|L7rchx=0|9ajOMF0Q* diff --git a/ytmusicapi/locales/ru/LC_MESSAGES/base.po b/ytmusicapi/locales/ru/LC_MESSAGES/base.po index 370a6e44..79665003 100644 --- a/ytmusicapi/locales/ru/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/ru/LC_MESSAGES/base.po @@ -1,13 +1,13 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: 2023-01-01 09:48+0530\n" "Last-Translator: \n" "Language-Team: \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.2.2\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "художник" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "плейлист" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "песня" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "видео" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "станция" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "профиль" + +#: parsers/i18n.py:21 msgid "albums" msgstr "альбомы" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "синглы" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "ролики" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "плейлисты" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "связанные с" diff --git a/ytmusicapi/locales/tr/LC_MESSAGES/base.mo b/ytmusicapi/locales/tr/LC_MESSAGES/base.mo index d6c83cdd67aa8cc7a06d4cd079d985db5cccf73e..528ab469ad0f3dd8a5a60443ad01b66136551d1a 100644 GIT binary patch delta 316 zcmXZWF%Q8|6o%n@t5u~DtPE^SA~BeaMvKH^LL?O;Vv^KmG&FXbS!`@plU2muuzF8> zPx9rtr%k`xyYqI!Z^=F?#wQiBB}MWfGF7cq8f(TnX4sqPVFz988hge*+B_eceS{AC z6s2#DKczyo<{(9ZmAJt?`wk0uK>6Tg-k(u^;DU1T&Fl}93!Y|wA=UA4j5~5DKPC62 kh*IUGM2=D_1u|LUN1krD%hh5s3&z)$9_VzkM0>0K2Ok?7a{vGU delta 285 zcmXZYtq#I46o%n;to$ZqLyd$43l*PK*aDX)&1t%Djry -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: 2023-01-02 13:06+0530\n" "Last-Translator: \n" "Language-Team: \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.2.2\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "sanatçı" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "çalma listesi" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "şarkı" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "video" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "istasyon" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "profil" + +#: parsers/i18n.py:21 msgid "albums" msgstr "albümler" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "bekarlar" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "videolar" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "çalma listeleri" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "ilişkili" diff --git a/ytmusicapi/locales/update_po.sh b/ytmusicapi/locales/update_po.sh index 1f09f84f..37946e0a 100644 --- a/ytmusicapi/locales/update_po.sh +++ b/ytmusicapi/locales/update_po.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash for dir in * do - if [ ${#dir} -eq 2 ]; + if (( ${#dir} >=2 && ${#dir} <=5 )); then msgmerge --update ${dir}/LC_MESSAGES/base.po base.pot --no-fuzzy-matching fi diff --git a/ytmusicapi/locales/ur/LC_MESSAGES/base.mo b/ytmusicapi/locales/ur/LC_MESSAGES/base.mo index 6822aec6f6498df47ea2188928944385df321659..95cb72d83fd22ec39f4679f46e78dcab8e2d6214 100644 GIT binary patch delta 321 zcmXZWzY9T86u|NO@ceotRt7dE*~}JYG|6H@ib%?$#KNd#H!&GxGuW)=_adWzz+m+~ z@7+_U&pD^tx#wYi&G|bO*eN1LX2=1OjQ4YE5`9sg2y8j@n-Jod*sj`->sE_Ju-nC-;GZy9eRrIJ z;hEXtcIQ6+<+FDuE`&(PK6xNh@=1nhJCQP`W*4jU1FYc?D>ybM7}HO!pQEQ=Vz_6G zU)*9{B$HL7h3v4&V2>?4VgpY$FEBiOw)2af->rWjcWO}Tay+2Rgzri^`7aU5u(a3T Rwn38j+~~G&8<$_B*$-yS6MFyv diff --git a/ytmusicapi/locales/ur/LC_MESSAGES/base.po b/ytmusicapi/locales/ur/LC_MESSAGES/base.po index 4fe9f568..f7084962 100644 --- a/ytmusicapi/locales/ur/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/ur/LC_MESSAGES/base.po @@ -1,13 +1,13 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: 2023-01-02 22:18+0530\n" "Last-Translator: \n" "Language-Team: \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.2.2\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "فنکار" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "پلے لسٹ" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "نغمہ" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "ویڈیو" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "اسٹیشن" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "پروفائ" + +#: parsers/i18n.py:21 msgid "albums" msgstr "البمز" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "سنگلز" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "ویڈیوز" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "پلے لسٹس" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "متعلقہ" diff --git a/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.pot b/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.po similarity index 69% rename from ytmusicapi/locales/zh_CN/LC_MESSAGES/base.pot rename to ytmusicapi/locales/zh_CN/LC_MESSAGES/base.po index 07c7e7cb..800bf2eb 100644 --- a/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.pot +++ b/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.po @@ -1,14 +1,13 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# -#, fuzzy +# msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Bruce Zhang \n" "Language-Team: LANGUAGE \n" @@ -17,42 +16,46 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "音乐人" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "播放列表" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "歌曲" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "视频" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "电台" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "个人资料" + +#: parsers/i18n.py:21 msgid "albums" msgstr "专辑" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "单曲" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "视频" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "精选" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "粉丝可能还会喜欢" diff --git a/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po b/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po index 218f1336..8752f534 100644 --- a/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po @@ -1,14 +1,14 @@ # Translations for ytmusicapi -# Copyright (C) 2021 sigma67 +# Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-03-24 13:13+0100\n" +"POT-Creation-Date: 2023-08-20 16:00+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,42 +17,46 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "artist" msgstr "藝人" -#: parsers/browsing.py:22 +#: parsers/i18n.py:16 msgid "playlist" msgstr "播放清單" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "song" msgstr "歌曲" -#: parsers/browsing.py:23 +#: parsers/i18n.py:16 msgid "video" msgstr "影片" -#: parsers/browsing.py:24 +#: parsers/i18n.py:16 msgid "station" msgstr "電台" -#: parsers/browsing.py:128 +#: parsers/i18n.py:16 +msgid "profile" +msgstr "個人資料" + +#: parsers/i18n.py:21 msgid "albums" msgstr "專輯" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "singles" msgstr "單曲" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "videos" msgstr "影片" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "playlists" msgstr "精選收錄" -#: parsers/browsing.py:128 +#: parsers/i18n.py:21 msgid "related" msgstr "粉絲可能也會喜歡" diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index d22cdb65..97211d7c 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -117,6 +117,14 @@ def search(self, "artist": "Oasis", "shuffleId": "RDAOkjHYJjL1a3xspEyVkhHAsg", "radioId": "RDEMkjHYJjL1a3xspEyVkhHAsg" + }, + { + "category": "Profiles", + "resultType": "profile", + "title": "Taylor Swift Time", + "name": "@TaylorSwiftTime", + "browseId": "UCSCRK7XlVQ6fBdEl00kX6pQ", + "thumbnails": ... } ] @@ -127,7 +135,7 @@ def search(self, search_results = [] filters = [ 'albums', 'artists', 'playlists', 'community_playlists', 'featured_playlists', 'songs', - 'videos' + 'videos', 'profiles' ] if filter and filter not in filters: raise Exception( @@ -148,7 +156,7 @@ def search(self, if scope == scopes[0] and filter in filters[3:5]: raise Exception(f"{filter} cannot be set when searching library. " f"Please use one of the following filters or leave out the parameter: " - + ', '.join(filters[0:3]+filters[5:])) + + ', '.join(filters[0:3] + filters[5:])) params = get_search_params(filter, scope, ignore_spelling) if params: diff --git a/ytmusicapi/parsers/i18n.py b/ytmusicapi/parsers/i18n.py index cfb94b7d..816c382c 100644 --- a/ytmusicapi/parsers/i18n.py +++ b/ytmusicapi/parsers/i18n.py @@ -13,7 +13,7 @@ def __init__(self, language): @i18n def get_search_result_types(self): - return [_('artist'), _('playlist'), _('song'), _('video'), _('station')] + return [_('artist'), _('playlist'), _('song'), _('video'), _('station'), _('profile')] @i18n def parse_artist_contents(self, results: List) -> Dict: diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 58ef5c93..65ba488b 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -5,7 +5,7 @@ def get_search_result_type(result_type_local, result_types_local): if not result_type_local: return None - result_types = ['artist', 'playlist', 'song', 'video', 'station'] + result_types = ['artist', 'playlist', 'song', 'video', 'station', 'profile'] result_type_local = result_type_local.lower() # default to album since it's labeled with multiple values ('Single', 'EP', etc.) if result_type_local not in result_types_local: @@ -67,15 +67,18 @@ def parse_search_result(data, search_result_types, result_type, category): elif result_type == 'playlist': flex_item = get_flex_column_item(data, 1)['text']['runs'] has_author = len(flex_item) == default_offset + 3 - search_result['itemCount'] = nav(flex_item, - [default_offset + has_author * 2, 'text']).split(' ')[0] - search_result['author'] = None if not has_author else nav(flex_item, - [default_offset, 'text']) + search_result['itemCount'] = get_item_text(data, 1, + default_offset + has_author * 2).split(' ')[0] + search_result['author'] = None if not has_author else get_item_text( + data, 1, default_offset) elif result_type == 'station': search_result['videoId'] = nav(data, NAVIGATION_VIDEO_ID) search_result['playlistId'] = nav(data, NAVIGATION_PLAYLIST_ID) + elif result_type == 'profile': + search_result['name'] = get_item_text(data, 1, 2) + elif result_type == 'song': search_result['album'] = None if 'menu' in data: @@ -124,7 +127,7 @@ def parse_search_result(data, search_result_types, result_type, category): song_info = parse_song_runs(runs) search_result.update(song_info) - if result_type in ['artist', 'album', 'playlist']: + if result_type in ['artist', 'album', 'playlist', 'profile']: search_result['browseId'] = nav(data, NAVIGATION_BROWSE_ID, True) if result_type in ['song', 'album']: @@ -143,7 +146,7 @@ def parse_search_results(results, search_result_types, resultType=None, category def get_search_params(filter, scope, ignore_spelling): - filtered_param1 = 'EgWKAQI' + filtered_param1 = 'EgWKAQ' params = None if filter is None and scope is None and not ignore_spelling: return params @@ -194,7 +197,14 @@ def get_search_params(filter, scope, ignore_spelling): def _get_param2(filter): - filter_params = {'songs': 'I', 'videos': 'Q', 'albums': 'Y', 'artists': 'g', 'playlists': 'o'} + filter_params = { + 'songs': 'II', + 'videos': 'IQ', + 'albums': 'IY', + 'artists': 'Ig', + 'playlists': 'Io', + 'profiles': 'JY' + } return filter_params[filter] From 0ef4656e1f9d584ee00dae2e411bf1c474c667fd Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 20 Aug 2023 17:46:03 +0200 Subject: [PATCH 166/238] update readme, docs (closes #315, #250) --- README.rst | 7 +++++ docs/source/faq.rst | 69 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index ec93b86c..ab6019eb 100644 --- a/README.rst +++ b/README.rst @@ -63,10 +63,17 @@ Features * upload songs and remove them again * list uploaded songs, artists and albums +| **Localization**: + +* all regions are supported (see `locations FAQ `__ +* 16 languages are supported (see `languages FAQ `__ + + If you find something missing or broken, check the `FAQ `__ or feel free to create an `issue `__. + Usage ------ .. code-block:: python diff --git a/docs/source/faq.rst b/docs/source/faq.rst index 239f2925..b554f40d 100644 --- a/docs/source/faq.rst +++ b/docs/source/faq.rst @@ -49,6 +49,13 @@ Can I download songs? *********************************************************************** You can use `youtube-dl `_ for this purpose. +How do I package ytmusicapi with ``pyinstaller``? +************************************************* + +To package ytmusicapi correctly, you need to add the locales files to your executable. + +You can use ``--add-data path-to-ytmusicapi/locales`` or ``--collect-all ytmusicapi`` to accomplish this. + YouTube Music API Internals ------------------------------ @@ -93,10 +100,70 @@ indicates the server-side pagination increment. ytmusicapi will keep fetching co reached at least the ``limit`` parameter, and return all of these results. +Which values can I use for languages? +************************************* + +The `language` parameter determines the language of the returned results. +``ytmusicapi`` only supports a subset of the languages supported by YouTube Music, as translations need to be done manually. +Contributions are welcome, see `here for instructions `__. + +For the list of values you can use for the ``language`` parameter, see below: + +.. raw:: html + +
+ Supported locations + +.. container:: + + .. list-table:: + + * - Language + - Value + * - Arabic + - ar + * - German + - de + * - English (default) + - en + * - Spanish + - es + * - French + - fr + * - Hindi + - hi + * - Italian + - it + * - Japanese + - ja + * - Korean + - ko + * - Dutch + - nl + * - Portuguese + - pt + * - Russian + - ru + * - Turkish + - tr + * - Urdu + - ur + * - Chinese (Mainland) + - zh_CN + * - Chinese (Taiwan) + - zh_TW + + +.. raw:: html + +
+ + + Which values can I use for locations? ************************************* -Pick a value from the list below for your desired location and pass it using the `location` parameter. +Pick a value from the list below for your desired location and pass it using the ``location`` parameter. .. raw:: html From 6660058a47dc31cefe1c3686067aeef0333c2590 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 20 Aug 2023 18:16:43 +0200 Subject: [PATCH 167/238] fix weird new chromium pasting bug - please use firefox! (closes #409) --- docs/source/setup/browser.rst | 2 +- ytmusicapi/auth/browser.py | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/source/setup/browser.rst b/docs/source/setup/browser.rst index b146b525..d79567f0 100644 --- a/docs/source/setup/browser.rst +++ b/docs/source/setup/browser.rst @@ -20,7 +20,7 @@ To do so, follow these steps: .. raw:: html
- Firefox + Firefox (recommended) .. container:: diff --git a/ytmusicapi/auth/browser.py b/ytmusicapi/auth/browser.py index 65c60a05..6f55218f 100644 --- a/ytmusicapi/auth/browser.py +++ b/ytmusicapi/auth/browser.py @@ -29,11 +29,19 @@ def setup_browser(filepath=None, headers_raw=None): try: user_headers = {} + chrome_remembered_key = "" for content in contents: header = content.split(": ") - if len(header) == 1 or header[0].startswith( - ":"): # nothing was split or chromium headers + if header[0].startswith(":"): # nothing was split or chromium headers continue + if header[0].endswith(":"): # pragma: no cover + # weird new chrome "copy-paste in separate lines" format + chrome_remembered_key = content.replace(":", "") + if len(header) == 1: + if chrome_remembered_key: # pragma: no cover + user_headers[chrome_remembered_key] = header[0] + continue + user_headers[header[0].lower()] = ": ".join(header[1:]) except Exception as e: From c5dca0ae3487eac39455d52fb445a23f59af05b9 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Fri, 25 Aug 2023 22:20:25 +0200 Subject: [PATCH 168/238] search: fix profile edge case (closes #429) --- ytmusicapi/parsers/search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 65ba488b..4ae415c2 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -77,7 +77,7 @@ def parse_search_result(data, search_result_types, result_type, category): search_result['playlistId'] = nav(data, NAVIGATION_PLAYLIST_ID) elif result_type == 'profile': - search_result['name'] = get_item_text(data, 1, 2) + search_result['name'] = get_item_text(data, 1, 2, True) elif result_type == 'song': search_result['album'] = None From 56d54a2b50f242abe812cd8214b31ede98cb1d01 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 27 Aug 2023 23:08:50 +0200 Subject: [PATCH 169/238] fix readthedocs --- .readthedocs.yml | 1 + docs/source/conf.py | 19 +------------------ docs/source/requirements.txt | 2 ++ tests/test.py | 4 ++-- 4 files changed, 6 insertions(+), 20 deletions(-) create mode 100644 docs/source/requirements.txt diff --git a/.readthedocs.yml b/.readthedocs.yml index 2cab60b2..6a01ec81 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -7,6 +7,7 @@ build: python: install: + - requirements: docs/source/requirements.txt - method: pip path: . diff --git a/docs/source/conf.py b/docs/source/conf.py index 88c05c1b..c9dd88f8 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -50,21 +50,4 @@ # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] -# -- Options for HTML output ------------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -if not on_rtd: - # Try to use the ReadTheDocs theme if installed. - # Default to the default alabaster theme if not. - try: - import sphinx_rtd_theme - html_theme = 'sphinx_rtd_theme' - html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] - except ImportError: - html_theme = 'alabaster' -else: - # Set theme to 'default' for ReadTheDocs. - html_theme = 'default' - +html_theme = "sphinx_rtd_theme" \ No newline at end of file diff --git a/docs/source/requirements.txt b/docs/source/requirements.txt new file mode 100644 index 00000000..15afc850 --- /dev/null +++ b/docs/source/requirements.txt @@ -0,0 +1,2 @@ +sphinx<=7 +sphinx-rtd-theme \ No newline at end of file diff --git a/tests/test.py b/tests/test.py index 3304804f..f5443700 100644 --- a/tests/test.py +++ b/tests/test.py @@ -83,7 +83,7 @@ def test_search(self): query = "edm playlist" self.assertRaises(Exception, self.yt_auth.search, query, filter="song") self.assertRaises(Exception, self.yt_auth.search, query, scope="upload") - queries = ["taylor swift", "taylor swift blank space", "taylor swift fearless"] + queries = ["Maylssi", "qllwlwl", "heun"] for q in queries: with self.subTest(): results = self.yt_brand.search(q) @@ -413,7 +413,7 @@ def test_subscribe_artists(self): def test_get_playlist_foreign(self): self.assertRaises(Exception, self.yt.get_playlist, "PLABC") - playlist = self.yt.get_playlist(sample_playlist, limit=300, suggestions_limit=7) + playlist = self.yt.get_playlist("PLPK7133-0ahmzknIfvNUMNJglX-O1rTd2", limit=300, suggestions_limit=7) self.assertGreater(len(playlist['duration']), 5) self.assertGreater(len(playlist["tracks"]), 200) self.assertNotIn("suggestions", playlist) From f1141d3f5a761055e575ee92eea5dace6a840c47 Mon Sep 17 00:00:00 2001 From: ankit yadav Date: Mon, 25 Sep 2023 22:52:20 +0530 Subject: [PATCH 170/238] fixed KeyError in get_charts --- ytmusicapi/navigation.py | 2 ++ ytmusicapi/parsers/browsing.py | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index f8c105cc..226747c1 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -26,6 +26,8 @@ ] WATCH_VIDEO_ID = ['watchEndpoint', 'videoId'] NAVIGATION_VIDEO_ID = ['navigationEndpoint'] + WATCH_VIDEO_ID +QUEUE_VIDEO_ID = ['queueAddEndpoint','queueTarget','videoId'] +NAVIGATION_VIDEO_ID_2 = MENU_ITEMS+[0]+MENU_SERVICE+QUEUE_VIDEO_ID NAVIGATION_PLAYLIST_ID = ['navigationEndpoint', 'watchEndpoint', 'playlistId'] NAVIGATION_WATCH_PLAYLIST_ID = ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'] NAVIGATION_VIDEO_TYPE = [ diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index c221cd9a..56d9ce70 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -103,9 +103,12 @@ def parse_song_flat(data): def parse_video(result): runs = nav(result, SUBTITLE_RUNS) artists_len = get_dot_separator_index(runs) + videoId = nav(result, NAVIGATION_VIDEO_ID, True) + if not videoId: + videoId = nav(result, NAVIGATION_VIDEO_ID_2) return { 'title': nav(result, TITLE_TEXT), - 'videoId': nav(result, NAVIGATION_VIDEO_ID), + 'videoId': videoId, 'artists': parse_song_artists_runs(runs[:artists_len]), 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), 'thumbnails': nav(result, THUMBNAIL_RENDERER, True), From 6c6f19df24b21c114300a0cacb48fbfb30f6012f Mon Sep 17 00:00:00 2001 From: Fabian Wunsch Date: Mon, 25 Sep 2023 21:47:47 +0200 Subject: [PATCH 171/238] Fix the currently present problem of `get_artist_albums` not working This doesn't touch singles at all, as I'm frankly too dumb to figure that out Fixes #439 --- ytmusicapi/mixins/browsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index bec7a56d..5a1e25b2 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -252,7 +252,7 @@ def get_artist_albums(self, channelId: str, params: str) -> List[Dict]: body = {"browseId": channelId, "params": params} endpoint = 'browse' response = self._send_request(endpoint, body) - results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + GRID_ITEMS) + results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + CAROUSEL_CONTENTS) albums = parse_albums(results) return albums From 01f38878dccb1d82d11b8c73a2772582c6ae61af Mon Sep 17 00:00:00 2001 From: Fabian Wunsch Date: Mon, 25 Sep 2023 21:59:27 +0200 Subject: [PATCH 172/238] Singles still use the grid renderer, while albums use the carousel now --- ytmusicapi/mixins/browsing.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 5a1e25b2..7725ffd2 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -252,7 +252,11 @@ def get_artist_albums(self, channelId: str, params: str) -> List[Dict]: body = {"browseId": channelId, "params": params} endpoint = 'browse' response = self._send_request(endpoint, body) - results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + CAROUSEL_CONTENTS) + results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM) + if 'gridRenderer' in results: + results = nav(results, GRID_ITEMS) + else: + results = nav(results, CAROUSEL_CONTENTS) albums = parse_albums(results) return albums From 24024aa18e84776452e3ff95dbc447804aadb064 Mon Sep 17 00:00:00 2001 From: Fabian Wunsch Date: Mon, 25 Sep 2023 22:21:15 +0200 Subject: [PATCH 173/238] cleaner alternative parsing --- ytmusicapi/mixins/browsing.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 7725ffd2..91fa491c 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -253,10 +253,7 @@ def get_artist_albums(self, channelId: str, params: str) -> List[Dict]: endpoint = 'browse' response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM) - if 'gridRenderer' in results: - results = nav(results, GRID_ITEMS) - else: - results = nav(results, CAROUSEL_CONTENTS) + results = nav(results, GRID_ITEMS, True) or nav(results, CAROUSEL_CONTENTS) albums = parse_albums(results) return albums From 0acef5ddd907f02d3c98044d37ef4c51ceef0520 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 27 Sep 2023 21:36:32 +0200 Subject: [PATCH 174/238] edit_playlist: add addToTop function parameter (closes #444) toggles a playlist from default behaviour (add to bottom) to "add to top" --- tests/test.py | 11 +++++++---- ytmusicapi/mixins/playlists.py | 15 +++++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/tests/test.py b/tests/test.py index f5443700..59f09a9d 100644 --- a/tests/test.py +++ b/tests/test.py @@ -83,14 +83,14 @@ def test_search(self): query = "edm playlist" self.assertRaises(Exception, self.yt_auth.search, query, filter="song") self.assertRaises(Exception, self.yt_auth.search, query, scope="upload") - queries = ["Maylssi", "qllwlwl", "heun"] + queries = ["Monekes", "qllwlwl", "heun"] for q in queries: with self.subTest(): results = self.yt_brand.search(q) self.assertListEqual(["resultType" in r for r in results], [True] * len(results)) - self.assertGreater(len(results), 10) + self.assertGreaterEqual(len(results), 10) results = self.yt.search(q) - self.assertGreater(len(results), 10) + self.assertGreaterEqual(len(results), 10) results = self.yt_auth.search("Martin Stig Andersen - Deteriation", ignore_spelling=True) self.assertGreater(len(results), 0) @@ -413,7 +413,9 @@ def test_subscribe_artists(self): def test_get_playlist_foreign(self): self.assertRaises(Exception, self.yt.get_playlist, "PLABC") - playlist = self.yt.get_playlist("PLPK7133-0ahmzknIfvNUMNJglX-O1rTd2", limit=300, suggestions_limit=7) + playlist = self.yt.get_playlist("PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", + limit=300, + suggestions_limit=7) self.assertGreater(len(playlist['duration']), 5) self.assertGreater(len(playlist["tracks"]), 200) self.assertNotIn("suggestions", playlist) @@ -468,6 +470,7 @@ def test_end2end(self): source_playlist="OLAK5uy_lGQfnMNGvYCRdDq9ZLzJV2BJL2aHQsz9Y", ) self.assertEqual(len(playlistId), 34, "Playlist creation failed") + self.yt_brand.edit_playlist(playlistId, addToTop=True) response = self.yt_brand.add_playlist_items( playlistId, [sample_video, sample_video], diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 1e6f8820..84462902 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -139,7 +139,8 @@ def get_playlist(self, 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'] + 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: @@ -147,7 +148,8 @@ def get_playlist(self, playlist['trackCount'] = song_count - request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams + ) # suggestions and related are missing e.g. on liked songs section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) @@ -230,7 +232,8 @@ def edit_playlist(self, description: str = None, privacyStatus: str = None, moveItem: Tuple[str, str] = None, - addPlaylistId: str = None) -> Union[str, Dict]: + addPlaylistId: str = None, + addToTop: bool = False) -> Union[str, Dict]: """ Edit title, description or privacyStatus of a playlist. You may also move an item within a playlist or append another playlist to this playlist. @@ -241,7 +244,8 @@ def edit_playlist(self, :param privacyStatus: Optional. New privacy status for the playlist :param moveItem: Optional. Move one item before another. Items are specified by setVideoId, see :py:func:`get_playlist` :param addPlaylistId: Optional. Id of another playlist to add to this playlist - :return: Status String or full response + :param addToTop: Optional. Change the state of this playlist to add items to the top of the playlist by default. + :return: Status String or full responwwwse """ self._check_auth() body = {'playlistId': validate_playlist_id(playlistId)} @@ -271,6 +275,9 @@ def edit_playlist(self, if addPlaylistId: actions.append({'action': 'ACTION_ADD_PLAYLIST', 'addedFullListId': addPlaylistId}) + if addToTop: + actions.append({'action': 'ACTION_SET_ADD_TO_TOP', 'addToTop': 'true'}) + body['actions'] = actions endpoint = 'browse/edit_playlist' response = self._send_request(endpoint, body) From 6f7dfce572a139d6f177b146ca3724caafd5e301 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 3 Oct 2023 16:02:20 +0200 Subject: [PATCH 175/238] fix rtd requirements.txt --- docs/source/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/requirements.txt b/docs/source/requirements.txt index 15afc850..699af989 100644 --- a/docs/source/requirements.txt +++ b/docs/source/requirements.txt @@ -1,2 +1,2 @@ -sphinx<=7 +sphinx<7 sphinx-rtd-theme \ No newline at end of file From 369ac9323f858ac0a7332f47e0a93f62c2f3a2ce Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 3 Oct 2023 16:02:49 +0200 Subject: [PATCH 176/238] edit_playlist: change addToTop function parameter to Optional[bool] (#444) --- tests/test.py | 1 + ytmusicapi/mixins/playlists.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/test.py b/tests/test.py index 59f09a9d..020fd18d 100644 --- a/tests/test.py +++ b/tests/test.py @@ -480,6 +480,7 @@ def test_end2end(self): self.assertEqual(response["status"], "STATUS_SUCCEEDED", "Adding playlist item failed") self.assertGreater(len(response["playlistEditResults"]), 0, "Adding playlist item failed") time.sleep(2) + self.yt_brand.edit_playlist(playlistId, addToTop=False) playlist = self.yt_brand.get_playlist(playlistId, related=True) self.assertEqual(len(playlist["tracks"]), 46, "Getting playlist items failed") response = self.yt_brand.remove_playlist_items(playlistId, playlist["tracks"]) diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 84462902..997918c9 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -1,4 +1,4 @@ -from typing import Dict, Union, Tuple +from typing import Dict, Union, Tuple, Optional from ._utils import * from ytmusicapi.continuations import * @@ -233,7 +233,7 @@ def edit_playlist(self, privacyStatus: str = None, moveItem: Tuple[str, str] = None, addPlaylistId: str = None, - addToTop: bool = False) -> Union[str, Dict]: + addToTop: Optional[bool] = None) -> Union[str, Dict]: """ Edit title, description or privacyStatus of a playlist. You may also move an item within a playlist or append another playlist to this playlist. @@ -244,8 +244,9 @@ def edit_playlist(self, :param privacyStatus: Optional. New privacy status for the playlist :param moveItem: Optional. Move one item before another. Items are specified by setVideoId, see :py:func:`get_playlist` :param addPlaylistId: Optional. Id of another playlist to add to this playlist - :param addToTop: Optional. Change the state of this playlist to add items to the top of the playlist by default. - :return: Status String or full responwwwse + :param addToTop: Optional. Change the state of this playlist to add items to the top of the playlist (if True) + or the bottom of the playlist (if False - this is also the default of a new playlist). + :return: Status String or full response """ self._check_auth() body = {'playlistId': validate_playlist_id(playlistId)} @@ -278,6 +279,9 @@ def edit_playlist(self, if addToTop: actions.append({'action': 'ACTION_SET_ADD_TO_TOP', 'addToTop': 'true'}) + if addToTop is not None: + actions.append({'action': 'ACTION_SET_ADD_TO_TOP', 'addToTop': str(addToTop)}) + body['actions'] = actions endpoint = 'browse/edit_playlist' response = self._send_request(endpoint, body) From d4a088d09f4a4891f752e2fef055e7449b0629fa Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 3 Oct 2023 16:25:31 +0200 Subject: [PATCH 177/238] fix: add new cookie value to bypass consent screen --- ytmusicapi/ytmusic.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index f63ad351..26a47198 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -83,7 +83,9 @@ def __init__(self, self._session = requests.api self.proxies = proxies - self.cookies = {'CONSENT': 'YES+1'} + # see google cookie docs: https://policies.google.com/technologies/cookies + # value from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L502 + self.cookies = {'SOCS': 'CAI'} if self.auth is not None: input_json = load_headers_file(self.auth) self.input_dict = CaseInsensitiveDict(input_json) @@ -130,12 +132,10 @@ def __init__(self, except KeyError: raise Exception("Your cookie is missing the required value __Secure-3PAPISID") - - def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") -> Dict: if self.is_oauth_auth: - self.headers = prepare_headers(self._session, self.proxies, self.input_dict) + self.headers = prepare_headers(self._session, self.proxies, self.input_dict) body.update(self.context) params = YTM_PARAMS if self.is_browser_auth: From 9a87990055a192806ae984f95b6075f25259e655 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 3 Oct 2023 16:57:37 +0200 Subject: [PATCH 178/238] get_album: don't overwrite parsed artist (closes #446) --- tests/test.py | 3 +++ ytmusicapi/mixins/browsing.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test.py b/tests/test.py index 020fd18d..dd2640e3 100644 --- a/tests/test.py +++ b/tests/test.py @@ -212,6 +212,9 @@ def test_get_album(self): self.assertEqual(len(results["other_versions"]), 2) results = self.yt.get_album("MPREb_BQZvl3BFGay") self.assertEqual(len(results["tracks"]), 7) + self.assertEqual(len(results["tracks"][0]["artists"]), 1) + results = self.yt.get_album("MPREb_rqH94Zr3NN0") + self.assertEqual(len(results["tracks"][0]["artists"]), 2) def test_get_song(self): song = self.yt_oauth.get_song(config["uploads"]["private_upload_id"]) # private upload diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 91fa491c..3a455431 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -419,7 +419,7 @@ def get_album(self, browseId: str) -> Dict: album['duration_seconds'] = sum_total_duration(album) for i, track in enumerate(album['tracks']): album['tracks'][i]['album'] = album['title'] - album['tracks'][i]['artists'] = album['artists'] + album['tracks'][i]['artists'] = album['tracks'][i]['artists'] or album['artists'] return album From 40e5650de3bfcb7d78b15175c82ee112cef9c87d Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 3 Oct 2023 17:09:49 +0200 Subject: [PATCH 179/238] search: fix missing artists when albums filter is set --- ytmusicapi/parsers/search.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 4ae415c2..8a7193a8 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -44,7 +44,7 @@ def parse_top_result(data, search_result_types): def parse_search_result(data, search_result_types, result_type, category): - default_offset = (not result_type) * 2 + default_offset = (not result_type or result_type == "album") * 2 search_result = {'category': category} video_type = nav(data, PLAY_BUTTON + ['playNavigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) if not result_type and video_type: From 05a5fb81533089716ce38516dafcdda50c606917 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Fri, 6 Oct 2023 21:26:09 +0200 Subject: [PATCH 180/238] search: add browseId to album top results (closes #448) --- ytmusicapi/parsers/search.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 8a7193a8..7044e44b 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -39,6 +39,9 @@ def parse_top_result(data, search_result_types): song_info = parse_song_runs(runs) search_result.update(song_info) + if result_type in ['album']: + search_result['browseId'] = nav(data, NAVIGATION_BROWSE_ID, True) + search_result['thumbnails'] = nav(data, THUMBNAILS, True) return search_result From 437844fa79c0219d727fbf5fdb5ce40248921e53 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 24 Oct 2023 22:13:10 +0200 Subject: [PATCH 181/238] get_playlist, get_library_songs, get_history, get_liked_songs: fix feedbackTokens, add inLibrary flag (closes #454) --- tests/test.py | 8 ++++++-- ytmusicapi/parsers/playlists.py | 3 +++ ytmusicapi/parsers/search.py | 1 + ytmusicapi/parsers/songs.py | 12 ++++++++++-- ytmusicapi/parsers/watch.py | 4 +++- 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/test.py b/tests/test.py index dd2640e3..2f313845 100644 --- a/tests/test.py +++ b/tests/test.py @@ -393,10 +393,14 @@ def test_rate_song(self): def test_edit_song_library_status(self): album = self.yt_brand.get_album(sample_album) response = self.yt_brand.edit_song_library_status( - album["tracks"][2]["feedbackTokens"]["add"]) + album["tracks"][0]["feedbackTokens"]["add"]) + album = self.yt_brand.get_album(sample_album) + self.assertTrue(album["tracks"][0]["inLibrary"]) self.assertTrue(response["feedbackResponses"][0]["isProcessed"]) response = self.yt_brand.edit_song_library_status( - album["tracks"][2]["feedbackTokens"]["remove"]) + album["tracks"][0]["feedbackTokens"]["remove"]) + album = self.yt_brand.get_album(sample_album) + self.assertFalse(album["tracks"][0]["inLibrary"]) self.assertTrue(response["feedbackResponses"][0]["isProcessed"]) def test_rate_playlist(self): diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index 4fdecb97..68976474 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -13,6 +13,7 @@ def parse_playlist_items(results, menu_entries: List[List] = None): videoId = setVideoId = None like = None feedback_tokens = None + library_status = None # if the item has a menu, find its setVideoId if 'menu' in data: @@ -27,6 +28,7 @@ def parse_playlist_items(results, menu_entries: List[List] = None): 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: @@ -73,6 +75,7 @@ def parse_playlist_items(results, menu_entries: List[List] = None): 'artists': artists, 'album': album, 'likeStatus': like, + 'inLibrary': library_status, 'thumbnails': thumbnails, 'isAvailable': isAvailable, 'isExplicit': isExplicit, diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 7044e44b..3713eaeb 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -87,6 +87,7 @@ def parse_search_result(data, search_result_types, result_type, category): if 'menu' in data: toggle_menu = find_object_by_key(nav(data, MENU_ITEMS), TOGGLE_MENU) if toggle_menu: + search_result['inLibrary'] = parse_song_library_status(toggle_menu) search_result['feedbackTokens'] = parse_song_menu_tokens(toggle_menu) elif result_type == 'upload': diff --git a/ytmusicapi/parsers/songs.py b/ytmusicapi/parsers/songs.py index f679bbeb..1c65d733 100644 --- a/ytmusicapi/parsers/songs.py +++ b/ytmusicapi/parsers/songs.py @@ -62,13 +62,21 @@ def parse_song_album(data, index): } +def parse_song_library_status(item) -> bool: + """Returns True if song is in the library""" + library_status = nav(item, [TOGGLE_MENU, 'defaultIcon', 'iconType'], True) + + return library_status == "LIBRARY_SAVED" + + def parse_song_menu_tokens(item): toggle_menu = item[TOGGLE_MENU] - service_type = toggle_menu['defaultIcon']['iconType'] + library_add_token = nav(toggle_menu, ['defaultServiceEndpoint'] + FEEDBACK_TOKEN, True) library_remove_token = nav(toggle_menu, ['toggledServiceEndpoint'] + FEEDBACK_TOKEN, True) - if service_type == "LIBRARY_REMOVE": # swap if already in library + in_library = parse_song_library_status(item) + if in_library: library_add_token, library_remove_token = library_remove_token, library_add_token return {'add': library_add_token, 'remove': library_remove_token} diff --git a/ytmusicapi/parsers/watch.py b/ytmusicapi/parsers/watch.py index dbd3a034..d1a3cf5f 100644 --- a/ytmusicapi/parsers/watch.py +++ b/ytmusicapi/parsers/watch.py @@ -26,9 +26,10 @@ def parse_watch_playlist(results): def parse_watch_track(data): - feedback_tokens = like_status = None + feedback_tokens = like_status = library_status = None for item in nav(data, MENU_ITEMS): if TOGGLE_MENU in item: + library_status = parse_song_library_status(item) service = item[TOGGLE_MENU]['defaultServiceEndpoint'] if 'feedbackEndpoint' in service: feedback_tokens = parse_song_menu_tokens(item) @@ -44,6 +45,7 @@ def parse_watch_track(data): 'thumbnail': nav(data, THUMBNAIL), 'feedbackTokens': feedback_tokens, 'likeStatus': like_status, + 'inLibrary': library_status, 'videoType': nav(data, ['navigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) } track.update(song_info) From 7382f622c52ea7b41a99e12e11227890f7d26e62 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 24 Oct 2023 22:14:11 +0200 Subject: [PATCH 182/238] fix requests_session documentation (closes #464) --- ytmusicapi/ytmusic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 26a47198..29bc187d 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -52,7 +52,7 @@ def __init__(self, s = requests.Session() s.request = functools.partial(s.request, timeout=3) - ytm = YTMusic(session=s) + ytm = YTMusic(requests_session=s) A falsy value disables sessions. It is generally a good idea to keep sessions enabled for From 2d2ab999a283200c89cf5a13a3ccf854b3dcc7f2 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 24 Oct 2023 22:21:46 +0200 Subject: [PATCH 183/238] don't fail on missing setVideoId/removedVideoId (closes #462, closes #463) --- ytmusicapi/parsers/playlists.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index 68976474..655f2660 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -21,10 +21,8 @@ def parse_playlist_items(results, menu_entries: List[List] = None): if 'menuServiceItemRenderer' in item: menu_service = nav(item, MENU_SERVICE) if 'playlistEditEndpoint' in menu_service: - setVideoId = menu_service['playlistEditEndpoint']['actions'][0][ - 'setVideoId'] - videoId = menu_service['playlistEditEndpoint']['actions'][0][ - 'removedVideoId'] + 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) From 07f690b8ae41d48ca5eeadb238c0ec9f29cddd5b Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 9 Nov 2023 18:40:38 +0100 Subject: [PATCH 184/238] fix get_album_browse_id (closes #470) --- ytmusicapi/mixins/browsing.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 3a455431..61cf9ab9 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -341,10 +341,10 @@ def get_album_browse_id(self, audioPlaylistId: str) -> str: """ params = {"list": audioPlaylistId} response = self._send_get_request(YTM_DOMAIN + "/playlist", params) - matches = re.findall(r"\"MPRE.+?\"", response.text) + matches = re.search(r"\"MPRE.+?\"", response.text.encode("utf8").decode("unicode_escape")) browse_id = None - if len(matches) > 0: - browse_id = matches[0].encode('utf8').decode('unicode-escape').strip('"') + if matches: + browse_id = matches.group().strip('"') return browse_id def get_album(self, browseId: str) -> Dict: From 9b0d4de7cca71d11248f81aa41972f62a439ac69 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Thu, 9 Nov 2023 19:05:38 +0100 Subject: [PATCH 185/238] fix get_charts fallback --- ytmusicapi/navigation.py | 1 - ytmusicapi/parsers/browsing.py | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 226747c1..3357f4d8 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -27,7 +27,6 @@ WATCH_VIDEO_ID = ['watchEndpoint', 'videoId'] NAVIGATION_VIDEO_ID = ['navigationEndpoint'] + WATCH_VIDEO_ID QUEUE_VIDEO_ID = ['queueAddEndpoint','queueTarget','videoId'] -NAVIGATION_VIDEO_ID_2 = MENU_ITEMS+[0]+MENU_SERVICE+QUEUE_VIDEO_ID NAVIGATION_PLAYLIST_ID = ['navigationEndpoint', 'watchEndpoint', 'playlistId'] NAVIGATION_WATCH_PLAYLIST_ID = ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'] NAVIGATION_VIDEO_TYPE = [ diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 56d9ce70..5fefab0b 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -105,7 +105,8 @@ def parse_video(result): artists_len = get_dot_separator_index(runs) videoId = nav(result, NAVIGATION_VIDEO_ID, True) if not videoId: - videoId = nav(result, NAVIGATION_VIDEO_ID_2) + videoId = next(id for entry in nav(result, MENU_ITEMS) + if nav(entry, MENU_SERVICE + QUEUE_VIDEO_ID, True)) return { 'title': nav(result, TITLE_TEXT), 'videoId': videoId, From 6f80e9442478fad28d65009ae56b871aa5b8c11f Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 11 Nov 2023 17:05:13 +0100 Subject: [PATCH 186/238] search: support podcasts and episodes --- ytmusicapi/locales/ar/LC_MESSAGES/base.mo | Bin 799 -> 879 bytes ytmusicapi/locales/ar/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/base.pot | 10 +++++++++- ytmusicapi/locales/de/LC_MESSAGES/base.mo | Bin 759 -> 789 bytes ytmusicapi/locales/de/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/en/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/es/LC_MESSAGES/base.mo | Bin 793 -> 867 bytes ytmusicapi/locales/es/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/fr/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/hi/LC_MESSAGES/base.mo | Bin 838 -> 938 bytes ytmusicapi/locales/hi/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/it/LC_MESSAGES/base.mo | Bin 679 -> 727 bytes ytmusicapi/locales/it/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/ja/LC_MESSAGES/base.mo | Bin 825 -> 919 bytes ytmusicapi/locales/ja/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/ko/LC_MESSAGES/base.mo | Bin 776 -> 858 bytes ytmusicapi/locales/ko/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/nl/LC_MESSAGES/base.mo | Bin 803 -> 838 bytes ytmusicapi/locales/nl/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/pt/LC_MESSAGES/base.mo | Bin 743 -> 777 bytes ytmusicapi/locales/pt/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/ru/LC_MESSAGES/base.mo | Bin 788 -> 872 bytes ytmusicapi/locales/ru/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/tr/LC_MESSAGES/base.mo | Bin 730 -> 762 bytes ytmusicapi/locales/tr/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/ur/LC_MESSAGES/base.mo | Bin 755 -> 842 bytes ytmusicapi/locales/ur/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/zh_CN/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po | 12 ++++++++++-- ytmusicapi/mixins/search.py | 2 +- ytmusicapi/parsers/i18n.py | 2 +- ytmusicapi/parsers/search.py | 6 ++++-- 32 files changed, 175 insertions(+), 37 deletions(-) diff --git a/ytmusicapi/locales/ar/LC_MESSAGES/base.mo b/ytmusicapi/locales/ar/LC_MESSAGES/base.mo index 4db42cab951a5eba9bb76f571d5e7651b595688a..cbbcbde39cd7e72fe06ec79c973d382bdc2fc9d3 100644 GIT binary patch delta 424 zcmXxfy-EW?5Ww-dGhX6HEEG{e@Cht!Y^(y>NIJ2S(*!&?x#KPt+5{2{5i5gOrcwMr zu{f}@^&R$_yn>|`7XD{l2j+ge!|u-9F!&6*quI)}5HsX5c}dQZKcvQ{ClcV6vx!yq zJ6OX#oWMg@&zz^up7R1LoV#-S8ypwu%8-RNxWg}eMjhtX|7%b<-*J)p2QJ|V_1rfu zV0BESjw?8gt2l}4sDn1#ejDf6@1jS4+gvX)jlDqKJ`8qZRYU(;L;qGA=%e(uF{R%y z-akIdCCoZe-jQ}1h8X&j!!TKHe&k|wQ#P%BwdG`tP3W?ss(vS%I#eSfX~P@bFD OvTyE9VV(wWe)J2V_&s0% delta 341 zcmYk$Jq`g;5Ww+SAM0B}B1jZ;I*me2r&MZWH6mGw*i{IL#tlR%4xw-YwF}??8kOk8 zf7UBZGWpHSOXkhfKG^GfKC%-cO;*SinIo^HM%5Crv1Y7eoW6kxY+(%B#;&o45$1i< z4=_qULftpPx5!$2iYx;+^jo~d68#3txW^)%FoPG=V{e$m2kJpD(|@o)p9qVvItWI= zrzzB<^l+Yu&hL6Hb%|c6p^NpCARKo%pLpWT{ITcD)O8l_f8iY$R>#_&L(OeGJo*B4 C$|J=9 diff --git a/ytmusicapi/locales/ar/LC_MESSAGES/base.po b/ytmusicapi/locales/ar/LC_MESSAGES/base.po index d1755d2b..128ad70c 100644 --- a/ytmusicapi/locales/ar/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/ar/LC_MESSAGES/base.po @@ -2,12 +2,12 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: 2023-01-02 22:14+0530\n" "Last-Translator: \n" "Language-Team: \n" @@ -41,6 +41,14 @@ msgstr "محطة" msgid "profile" msgstr "الملف الشخصي" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "بودكاست" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "حلقة" + #: parsers/i18n.py:21 msgid "albums" msgstr "ألبومات" diff --git a/ytmusicapi/locales/base.pot b/ytmusicapi/locales/base.pot index 87a762d6..50e564b4 100644 --- a/ytmusicapi/locales/base.pot +++ b/ytmusicapi/locales/base.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -41,6 +41,14 @@ msgstr "" msgid "profile" msgstr "" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "" + #: parsers/i18n.py:21 msgid "albums" msgstr "" diff --git a/ytmusicapi/locales/de/LC_MESSAGES/base.mo b/ytmusicapi/locales/de/LC_MESSAGES/base.mo index 6f298ba2fddccba62ac95aef907d2ba55ff70eae..8fb70b5e8db9af159ffd6ec9fe3e0044d0154e03 100644 GIT binary patch delta 352 zcmXYtyAAOT|G^WK#tW1|HXb&{-;bK6GY%`&`mfZ8c`&fxR#3 J{QerK{{cSNAwvKF delta 318 zcmXYts}90I6hybBOJ|EUB6PZvCc6M60hce)(`2^*{88+Y*%D_96!l$0UHGedJA=QakCLJXxkCKOH lurb}VOqMcK3PnXMKa$8>&NhBvymhz;!pp!~S%;fF`2!vf8zBGy diff --git a/ytmusicapi/locales/de/LC_MESSAGES/base.po b/ytmusicapi/locales/de/LC_MESSAGES/base.po index c9f119d1..d2e962b3 100644 --- a/ytmusicapi/locales/de/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/de/LC_MESSAGES/base.po @@ -2,13 +2,13 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -41,6 +41,14 @@ msgstr "sender" msgid "profile" msgstr "profil" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "Folge" + #: parsers/i18n.py:21 msgid "albums" msgstr "alben" diff --git a/ytmusicapi/locales/en/LC_MESSAGES/base.po b/ytmusicapi/locales/en/LC_MESSAGES/base.po index 20d5b9bd..141db84c 100644 --- a/ytmusicapi/locales/en/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/en/LC_MESSAGES/base.po @@ -2,13 +2,13 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -41,6 +41,14 @@ msgstr "" msgid "profile" msgstr "" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "" + #: parsers/i18n.py:21 msgid "albums" msgstr "" diff --git a/ytmusicapi/locales/es/LC_MESSAGES/base.mo b/ytmusicapi/locales/es/LC_MESSAGES/base.mo index 59d70f5229fc4ce6ca6fd4878c0807a16df935b7..11cde3b8ba9317685d9ee1c4b1601549702db650 100644 GIT binary patch delta 419 zcmXw#y-EW?6ov1uakKu!!a!1}?_g^!0R@p10R;)C~M@&g#d zW4Hj%ykB}=d0u6?o<=z)%0o^Fh*(iMr8J!15 z@)-@3rQ|-ZiE(jQN*N{_O4y|Qkv#lhwGSiZ@8V4qt8L&X!G9556K$boFWt)=WBLOO CJt39= diff --git a/ytmusicapi/locales/es/LC_MESSAGES/base.po b/ytmusicapi/locales/es/LC_MESSAGES/base.po index fd6bc80d..706a8b6a 100644 --- a/ytmusicapi/locales/es/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/es/LC_MESSAGES/base.po @@ -2,13 +2,13 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -41,6 +41,14 @@ msgstr "emisora" msgid "profile" msgstr "perfil" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "Pódcast" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "Episodio" + #: parsers/i18n.py:21 msgid "albums" msgstr "álbumes" diff --git a/ytmusicapi/locales/fr/LC_MESSAGES/base.po b/ytmusicapi/locales/fr/LC_MESSAGES/base.po index f6eae680..ca318518 100644 --- a/ytmusicapi/locales/fr/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/fr/LC_MESSAGES/base.po @@ -2,13 +2,13 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -41,6 +41,14 @@ msgstr "radio" msgid "profile" msgstr "profil" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "" + #: parsers/i18n.py:21 msgid "albums" msgstr "" diff --git a/ytmusicapi/locales/hi/LC_MESSAGES/base.mo b/ytmusicapi/locales/hi/LC_MESSAGES/base.mo index f27e3ec655866ad2557c7a59d023f3496322914e..b46ef7c4862faa7c8646d5c2fa87838d53ad95d3 100644 GIT binary patch delta 429 zcmXxgy-EW?5Ww-d7!!@3#758u@F}bmOlM~$a+;ullRNKXp-n(+Gy%a7QU+nu1RJpt z3vI+#u#JRe^m#G&f{`zaE z^#F0j~@`+UqeYJ)jt^?}pnIVrE{f5#0>2W4u z+G=JkX(Y`ot~Vr!!m~&l|BFnLxPB07CygIBgIch7yTuTxmBT(R)3 L@7+u9xzPOuOa?)b delta 341 zcmZ3*evGaDo)F7a1|Z-8Vi_Q=0b)TQz5~P{puot$zy_ohp|l2&<^b~bfix$OwgA%X zK-vjPdqL?SAk6|4kAm{!fix?SpAMu!=H&wE?+gs}3?)DY$nqQ}1_mJ@T?wS6fpia) zUIC?#0BH%J_yZsfviK*IW@d)y -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: 2023-01-01 11:04+0530\n" "Last-Translator: \n" "Language-Team: \n" @@ -41,6 +41,14 @@ msgstr "स्टेशन" msgid "profile" msgstr "प्रोफ़ाइल" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "पॉडकास्ट" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "एपिसोड" + #: parsers/i18n.py:21 msgid "albums" msgstr "एलबम" diff --git a/ytmusicapi/locales/it/LC_MESSAGES/base.mo b/ytmusicapi/locales/it/LC_MESSAGES/base.mo index 2b7bf739bd5109cfcddce8834eb5afe6779cc03a..80083b102e793bd6dedc7c3a6b1925cd03b78ad3 100644 GIT binary patch delta 315 zcmZ3^dY!fYo)F7a1|Z-DVi_PV0b)TQJ_E!cAi&7Lzy_oRp|k{$W(V@+fix$OR)_Kp zp|mBGb_CKaKzUCn-w#NG%n1Y1;y@e=#Huxtqxc0IKvB^8f$< diff --git a/ytmusicapi/locales/it/LC_MESSAGES/base.po b/ytmusicapi/locales/it/LC_MESSAGES/base.po index 117df805..c0d38927 100644 --- a/ytmusicapi/locales/it/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/it/LC_MESSAGES/base.po @@ -2,13 +2,13 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -41,6 +41,14 @@ msgstr "stazione" msgid "profile" msgstr "profilo" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "Puntata" + #: parsers/i18n.py:21 msgid "albums" msgstr "album" diff --git a/ytmusicapi/locales/ja/LC_MESSAGES/base.mo b/ytmusicapi/locales/ja/LC_MESSAGES/base.mo index 497f7e8396f4a0863efbe35625eca526bb0bd55a..cd58711dcc1688b20279ff058ac942aaa99293d0 100644 GIT binary patch delta 434 zcmXw#ze>bF5Qiu6y!h`1nulBC3=B7h$bYbpv^(HI zsMU2=zJSg82$mLp6V@TW{dRu4%gp4ZJ`DO_t=hE^O|*i}(LDM^8k?R-9d0>p!+?AT zPQYC_2|Lcu98Vn!$1_;t-lfZja9pG>w`{b)JzPYOXoh^`i?rY;oQ2;m|8We)L>BOu zq261At8g9a`v~$$#-iuPQ0ojFFQ7;L%4~2zH!_9rtG@xNj+GiZwT4cs1$26PsbX5! zFy1>l$R*5r-MlAJ+Rc-8BxxL;#A^I+a!Hf+eyC2CJa2d%uUR};b7hM+YhJAxl<)ph UIr8TkH`W+(TRd5FSAO`1f7QW35C8xG delta 345 zcmYk1u@1pd6oyX~RV--2V9-rskeJMDMx#Y45s_3PT7yL51#FFocd(gE-oY$3@8LUj z2Y>G0@0@#j&pr3--1CQbHG2|N#!bA(75u`gmL-yhzF`{{$h**medxe};mB|dv+Pey zK7~2*1=M~kShwULF@auw%!pLs#qetQFnmIv`UmR&jxE9xkB^>fKyBPH>_J+m;z(L6 zK^;m5r;LWb={3|>>4d5x)=N?a;cUB#B-q5uD3)~??85&by6vpcN(#1@xOOe++4C={ C-XK%} diff --git a/ytmusicapi/locales/ja/LC_MESSAGES/base.po b/ytmusicapi/locales/ja/LC_MESSAGES/base.po index 8ea993d7..876b4608 100644 --- a/ytmusicapi/locales/ja/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/ja/LC_MESSAGES/base.po @@ -2,13 +2,13 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -41,6 +41,14 @@ msgstr "ステーション" msgid "profile" msgstr "プロフィール" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "ポッドキャスト" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "エピソード" + #: parsers/i18n.py:21 msgid "albums" msgstr "アルバム" diff --git a/ytmusicapi/locales/ko/LC_MESSAGES/base.mo b/ytmusicapi/locales/ko/LC_MESSAGES/base.mo index d3a7560fbf2c94833cd9cd4ed8f64e70de6e821b..c7ad36a1396efc5e2cb83b6927ae4d24e631de28 100644 GIT binary patch delta 425 zcmeBRyTw+2Pl#nI0}${5u?!IR0I@I-{{dnU@L*(MU<1;gP&xoevjh3zK$-(c#{+3j zAe{jfFM`rlP`U|9cL8Y@pt?y={&XPC%D_<1FdxVOS+EpHiv#f*DE|l(0|O6G{4|ul z0i~Zp=}%DlKad7FfQOlZK?F#P0cntV+ECgENQ2C=1kxaNZcsV^NHYTM2P@_UN&sEM zzy-uWad2n=DX@z{Gzfrv4FX`xfHcVWK)b4NB$ehCGb9$3WEPh&q!wfr=clAH z6yzjU=70oXjADj@{FLNGkVsMfW>>}-M(*ho-cH%`rmg4o2dgRmr4#2Qp_ zUzffC1L)h3=b132kVCdyOnu%GGjS#EA%Exulkg0Az(ssOUj7OBxsUjT80{90?mJP) zOYw7lgsALo&qw|%-jFMT@KCpOqt!Pp(tCEtvZ-t61LOZ-9S2HNNiTQr(c-n;yw(eP MqnRAMW)}5Vf5I3t!2kdN diff --git a/ytmusicapi/locales/ko/LC_MESSAGES/base.po b/ytmusicapi/locales/ko/LC_MESSAGES/base.po index 75e3afa9..0150535b 100644 --- a/ytmusicapi/locales/ko/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/ko/LC_MESSAGES/base.po @@ -2,13 +2,13 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -41,6 +41,14 @@ msgstr "스테이션" msgid "profile" msgstr "프로필" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "팟캐스트" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "에피소드" + #: parsers/i18n.py:21 msgid "albums" msgstr "앨범" diff --git a/ytmusicapi/locales/nl/LC_MESSAGES/base.mo b/ytmusicapi/locales/nl/LC_MESSAGES/base.mo index b6620ebbaff0dbbb3ee8360e7ed00f0df5e696c4..c384442ff4dda62df0e66c81da5f5a9b8eac4ecb 100644 GIT binary patch delta 353 zcmZ3?c8sn5o)F7a1|Z-CVi_QA0b)TQegniHV8F=0zy_oZp|k~%W(V@^fiwq@b_dd& zKso>_9tEY7pmY|HW&z3ls>s3@;$=fe0{MXM$L84@h$X6+Q#f zJV5#*kmdu@|Df``%nS_NK)xuD202I(NP`@z3FR9AX^=%0P`(|I23i3IKqJ6C7Hz~45t8Pd>{O1^|`SA!+~s delta 318 zcmX@cwwSH{o)F7a1|Z-8Vi_Q=0b)TQz5~P{puot$zy_ohp|l2&<^b~bfix$OwgA%X zK-vjPdqL?SAk6|4kAm{!fix?SpAMu!=H&uupxSzd5~#p_CWt}DfHcT4=YTXHkiHES ze+8tuf&5QU{y!iMGKh^CqK_9ygDen*@}+?^&}c9KDg~ -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: 2023-04-23 14:55+0200\n" "Last-Translator: Heimen Stoffels \n" "Language-Team: \n" @@ -42,6 +42,14 @@ msgstr "station" msgid "profile" msgstr "profiel" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "Aflevering" + #: parsers/i18n.py:21 msgid "albums" msgstr "albums" diff --git a/ytmusicapi/locales/pt/LC_MESSAGES/base.mo b/ytmusicapi/locales/pt/LC_MESSAGES/base.mo index d3aa31ffe4f929e14ecaf8653a94615dd09d24ca..d93b51bdfe8bd017dfb5128c29e063d7353c8f4a 100644 GIT binary patch delta 352 zcmXYtyAAO9VTECCSk|qyM}$k5sYx} zn0NuB#6G02FKZ4T5?fQS3jGIcp*G&b0zASZJVRaR3e)fgweAjev8RdOQ0u-XZ-qq| z5r|Y!n~~Okn-mG|1L>=DV{NJpR60n1C{SLqHw0DmHhL}r0+XN@9kupq`J?ol_Kv4R)WgKww@-%b5MUH3HeAEb2}4AXDAUoWNm mbEHT=YobGamEKUJOzIy=!}Ay0AQW#Ct-|O!vL@EyWUv1#ZW`kN diff --git a/ytmusicapi/locales/pt/LC_MESSAGES/base.po b/ytmusicapi/locales/pt/LC_MESSAGES/base.po index 0be81fe4..cfe35c46 100644 --- a/ytmusicapi/locales/pt/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/pt/LC_MESSAGES/base.po @@ -2,12 +2,12 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: 2023-01-01 12:15+0530\n" "Last-Translator: \n" "Language-Team: \n" @@ -41,6 +41,14 @@ msgstr "estação" msgid "profile" msgstr "perfil" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "Episódio" + #: parsers/i18n.py:21 msgid "albums" msgstr "álbuns" diff --git a/ytmusicapi/locales/ru/LC_MESSAGES/base.mo b/ytmusicapi/locales/ru/LC_MESSAGES/base.mo index db8dd86a084e057a575275b530ce2236dc6bd59f..bef80e6e4d5206b4ba935356e8362cf0d51d08b1 100644 GIT binary patch delta 425 zcmXxfy-EW?5Ww-d^E{0jkpw#*KwrS#LWo^D3kx~Tfq|1d?qZ=$NUZ#*62&r&=>iI| zvWSfju=@fQR=$APSooh~9hm#=?9DQ}ci~sq{ip@^LadS-^bK77fb$|<8L`j-WBkDh>SZ2%ku{v+3ckDAVvYI}weQ;* z%!#a1*HQboa0$0j`*vL2bpH>~V}3cjN&~kk53pYzfbzzwhQ3-uU#kQ3^~^3~S-(+o zbaq-uly}-iN8+qqq^(%8Bsx!Y<9|^|mbQ)~-O1DGuGjD?1AAlM?4|7uul&vG$jr=( Oc{R_rXZyn^KmG%{4Lyng delta 341 zcmYk%y$%6E5Ww*{A9ubYNOTGkg-)xc6O~dUM zG>y8HE>4?h{kGSm{z^C0(82mh7_K*%PkeD_!PpOE>bVQ=|KOh%R>#_2BGqj?I{X4Z CfFpbW diff --git a/ytmusicapi/locales/ru/LC_MESSAGES/base.po b/ytmusicapi/locales/ru/LC_MESSAGES/base.po index 79665003..c9cc4934 100644 --- a/ytmusicapi/locales/ru/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/ru/LC_MESSAGES/base.po @@ -2,12 +2,12 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: 2023-01-01 09:48+0530\n" "Last-Translator: \n" "Language-Team: \n" @@ -41,6 +41,14 @@ msgstr "станция" msgid "profile" msgstr "профиль" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "Подкаст" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "Выпуск" + #: parsers/i18n.py:21 msgid "albums" msgstr "альбомы" diff --git a/ytmusicapi/locales/tr/LC_MESSAGES/base.mo b/ytmusicapi/locales/tr/LC_MESSAGES/base.mo index 528ab469ad0f3dd8a5a60443ad01b66136551d1a..a06de82fd3fb23eb5c8f485af65fdabe9c0396db 100644 GIT binary patch delta 350 zcmXZYJr4mv5P;!5oNt2KZxA}2L_;Y`Dnda{h$G5t=tLtDrGKE1s1y=~TB}fKX;c!G zckXtQ$+NRJnc3Xk{F%-p6WAysL1xK0nI_+)MBY#;hy`sCL)0Ybm%7x$i<_?n=%WzpF5hLXfxzV!P(}As8<4Mn+sP3p|4?EpM&Zrxq^5tk< KPJ^}MYyJVuT_r*Q delta 318 zcmXYtuM5IZ6ov2R=5IvJ25mNtW`n`FxFT9a*ovDbVg3gOv9FoMX0)2DBK{kK$?tCO zz~Q@RKlkM(pSE)nRL32>!!7*As;(tcggwJPERzSY0!Od}$A(iw4?2AJO`buQ zyoA~p!mmgoF@qKbcFrwqkoT|&4^R)J=KmS$1ujq*-%P$kUGOmZ38~J+n03^kUP|{B ku*fhk(NRmaKvl*1BAMpFViQI(U&pH`zD_L9I;OV&2Yg%_;Q#;t diff --git a/ytmusicapi/locales/tr/LC_MESSAGES/base.po b/ytmusicapi/locales/tr/LC_MESSAGES/base.po index e564733e..e3562d52 100644 --- a/ytmusicapi/locales/tr/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/tr/LC_MESSAGES/base.po @@ -2,12 +2,12 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: 2023-01-02 13:06+0530\n" "Last-Translator: \n" "Language-Team: \n" @@ -41,6 +41,14 @@ msgstr "istasyon" msgid "profile" msgstr "profil" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "Bölüm" + #: parsers/i18n.py:21 msgid "albums" msgstr "albümler" diff --git a/ytmusicapi/locales/ur/LC_MESSAGES/base.mo b/ytmusicapi/locales/ur/LC_MESSAGES/base.mo index 95cb72d83fd22ec39f4679f46e78dcab8e2d6214..a8347daa6b170b35f2bf706684703cdb5118cae2 100644 GIT binary patch delta 430 zcmXxfze~eV5Ww+EOzW>|sjeM#@E>sRDCp)OI7(~_79t5r9ds!=70F}~$1X}K2o9Ze z@DJ!;khb9L?$E`-@1^I#<@4^6ynFAbFfMk!w!B9nHpwcPlH24jsd4Ct6!FMB#%1bL zT){J3#S81l=8bu0-lNBN4^}^6L8K#n7CPVs=lF_i`0Orx!!q>;Zs6GJ4E508Si-sK zEiL}5pdPS_`hL&)`&QS`VSYYbH|8|?VEXySke^u9&|7QhZFNK4oSAv7>(dX;uPzhu z<8~`)OEYREVWTNg;9m#Y_+KOvg^jweoj4pFI5nr74QA=&bC&L8qe*w#?cKV&C0$I1 M*?`u2ukW7x0gqBaQ~&?~ delta 341 zcmYk$Jq`g;5Ww--WqpfKE9i7uEjk^gMph$|m55ytiA3TA64ASWN~hOs;Q~bI0t(Uj z&w7PPCcl}<%e)B_FK7KI#rHzw$TGPii{zcus8}KitQu>WqOW5b8<@nFv19CEoO$2$ z19a#|sQV`P7FkO`kzwH3KNpy%-(Uf^sK*_QN7VOc)I%<2el_!(>7U5zC@7;(v#3Yu x;W1LA{kvXEU8Wam=m+{~6s|X%PkeD_!PpOE>bXnrzwl2>t7+{*yR!S(-7kGWB#r<8 diff --git a/ytmusicapi/locales/ur/LC_MESSAGES/base.po b/ytmusicapi/locales/ur/LC_MESSAGES/base.po index f7084962..432bd5e5 100644 --- a/ytmusicapi/locales/ur/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/ur/LC_MESSAGES/base.po @@ -2,12 +2,12 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: 2023-01-02 22:18+0530\n" "Last-Translator: \n" "Language-Team: \n" @@ -41,6 +41,14 @@ msgstr "اسٹیشن" msgid "profile" msgstr "پروفائ" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "پوڈکاسٹ" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "ایپی سوڈ" + #: parsers/i18n.py:21 msgid "albums" msgstr "البمز" diff --git a/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.po b/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.po index 800bf2eb..1336889b 100644 --- a/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/zh_CN/LC_MESSAGES/base.po @@ -2,12 +2,12 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Bruce Zhang \n" "Language-Team: LANGUAGE \n" @@ -40,6 +40,14 @@ msgstr "电台" msgid "profile" msgstr "个人资料" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "播客" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "分集" + #: parsers/i18n.py:21 msgid "albums" msgstr "专辑" diff --git a/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po b/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po index 8752f534..cb3ba16f 100644 --- a/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/zh_TW/LC_MESSAGES/base.po @@ -2,13 +2,13 @@ # Copyright (C) 2023 sigma67 # This file is distributed under the same license as ytmusicapi # sigma67 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-08-20 16:00+0200\n" +"POT-Creation-Date: 2023-11-11 16:22+0100\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -41,6 +41,14 @@ msgstr "電台" msgid "profile" msgstr "個人資料" +#: parsers/i18n.py:16 +msgid "podcast" +msgstr "" + +#: parsers/i18n.py:16 +msgid "episode" +msgstr "單集" + #: parsers/i18n.py:21 msgid "albums" msgstr "專輯" diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 97211d7c..0eeac063 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -135,7 +135,7 @@ def search(self, search_results = [] filters = [ 'albums', 'artists', 'playlists', 'community_playlists', 'featured_playlists', 'songs', - 'videos', 'profiles' + 'videos', 'profiles', 'podcasts', 'episodes' ] if filter and filter not in filters: raise Exception( diff --git a/ytmusicapi/parsers/i18n.py b/ytmusicapi/parsers/i18n.py index 816c382c..d6cf3d22 100644 --- a/ytmusicapi/parsers/i18n.py +++ b/ytmusicapi/parsers/i18n.py @@ -13,7 +13,7 @@ def __init__(self, language): @i18n def get_search_result_types(self): - return [_('artist'), _('playlist'), _('song'), _('video'), _('station'), _('profile')] + return [_('artist'), _('playlist'), _('song'), _('video'), _('station'), _('profile'), _('podcast'), _('episode')] @i18n def parse_artist_contents(self, results: List) -> Dict: diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 3713eaeb..96d724b0 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -5,7 +5,7 @@ def get_search_result_type(result_type_local, result_types_local): if not result_type_local: return None - result_types = ['artist', 'playlist', 'song', 'video', 'station', 'profile'] + result_types = ['artist', 'playlist', 'song', 'video', 'station', 'profile', 'podcast', 'episode'] result_type_local = result_type_local.lower() # default to album since it's labeled with multiple values ('Single', 'EP', etc.) if result_type_local not in result_types_local: @@ -207,7 +207,9 @@ def _get_param2(filter): 'albums': 'IY', 'artists': 'Ig', 'playlists': 'Io', - 'profiles': 'JY' + 'profiles': 'JY', + 'podcasts': 'JQ', + 'episodes': 'JI' } return filter_params[filter] From f73f77b3871fa20e4621f3bc7a7dbb7ba94c68a3 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 11 Nov 2023 17:08:42 +0100 Subject: [PATCH 187/238] search: add podcasts filter tests --- tests/test.py | 6 ++ ytmusicapi/mixins/playlists.py | 138 ++++++++++++++++----------------- 2 files changed, 75 insertions(+), 69 deletions(-) diff --git a/tests/test.py b/tests/test.py index 2f313845..9aff93df 100644 --- a/tests/test.py +++ b/tests/test.py @@ -124,6 +124,12 @@ def test_search_filters(self): results = self.yt_auth.search("some user", filter="profiles") self.assertGreater(len(results), 10) self.assertTrue(all(item['resultType'] == 'profile' for item in results)) + results = self.yt_auth.search(query, filter="podcasts") + self.assertGreater(len(results), 10) + self.assertTrue(all(item['resultType'] == 'podcast' for item in results)) + results = self.yt_auth.search(query, filter="episodes") + self.assertGreater(len(results), 10) + self.assertTrue(all(item['resultType'] == 'episode' for item in results)) def test_search_uploads(self): self.assertRaises( diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 997918c9..3ea0dc33 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -109,77 +109,77 @@ def get_playlist(self, 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['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 = {} + # 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['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 + # request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams ) - - # suggestions and related are missing e.g. on liked songs - section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) - playlist['related'] = [] - if 'continuations' in section_list: - additionalParams = get_continuation_params(section_list) - if own_playlist 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) + # + # # suggestions and related are missing e.g. on liked songs + # section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) + # playlist['related'] = [] + # if 'continuations' in section_list: + # additionalParams = get_continuation_params(section_list) + # if own_playlist 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'] = [] if 'contents' in results: From e8cb80f3883d5a95cbd71341a05410663a4f71a8 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 11 Nov 2023 17:12:24 +0100 Subject: [PATCH 188/238] add fr episodes translation --- ytmusicapi/locales/fr/LC_MESSAGES/base.mo | Bin 644 -> 677 bytes ytmusicapi/locales/fr/LC_MESSAGES/base.po | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/locales/fr/LC_MESSAGES/base.mo b/ytmusicapi/locales/fr/LC_MESSAGES/base.mo index 91eea02eacd225a6e93777c7c3a6dc782d4003ed..6f3aa4bcef4a3dc25764d227e54e0c096ddd5d3c 100644 GIT binary patch delta 278 zcmZo+UCLU2Pl#nI0}yZlu?!IB05LBR?*U>E_yELgK>QJi*@5^65OV-A10#gb38e*r zGz(Bz63UkY(yTzf8juF**8$SvKx_=eAalH#z~6~wPXp2*1Lgv0pjlwR45dN(L5>6I14=V+0x<}H90u|rNFPIDQAuWT2}5c@W^sNB TW9r7|A&l&YJHfokmQ3CNTfG^{ delta 245 zcmZ3=+QM3YPl#nI0}yZmu?!HW05LBRuK{8ZcmTv~K>QGhIe_>D5VHgEM=1RhNV5Pj zBO^qd4M?*B`Fubcq)!A$ivzJV5QFp?GeOL;2GT(J3zAMJ1WVB^&>TFiwtP@&o{LG7~NU diff --git a/ytmusicapi/locales/fr/LC_MESSAGES/base.po b/ytmusicapi/locales/fr/LC_MESSAGES/base.po index ca318518..4773fa6a 100644 --- a/ytmusicapi/locales/fr/LC_MESSAGES/base.po +++ b/ytmusicapi/locales/fr/LC_MESSAGES/base.po @@ -47,7 +47,7 @@ msgstr "" #: parsers/i18n.py:16 msgid "episode" -msgstr "" +msgstr "Épisode" #: parsers/i18n.py:21 msgid "albums" From 38b49056da382be9a8614dc18fe61d13631a1e91 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sat, 11 Nov 2023 17:15:38 +0100 Subject: [PATCH 189/238] Revert "search: add podcasts filter tests" This reverts commit f73f77b3871fa20e4621f3bc7a7dbb7ba94c68a3. --- ytmusicapi/mixins/playlists.py | 138 ++++++++++++++++----------------- 1 file changed, 69 insertions(+), 69 deletions(-) diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 3ea0dc33..997918c9 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -109,77 +109,77 @@ def get_playlist(self, response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + ['musicPlaylistShelfRenderer']) - playlist = {} - # 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['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 = {'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['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 + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams ) - # - # # suggestions and related are missing e.g. on liked songs - # section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) - # playlist['related'] = [] - # if 'continuations' in section_list: - # additionalParams = get_continuation_params(section_list) - # if own_playlist 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) + + # suggestions and related are missing e.g. on liked songs + section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) + playlist['related'] = [] + if 'continuations' in section_list: + additionalParams = get_continuation_params(section_list) + if own_playlist 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'] = [] if 'contents' in results: From 43d3c3bc0455b73d44235d9d7adf7ad13aea35cc Mon Sep 17 00:00:00 2001 From: Benedikt Putz Date: Thu, 14 Dec 2023 13:53:29 +0100 Subject: [PATCH 190/238] .readthedocs.yml: enable all formats --- .readthedocs.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 6a01ec81..b952cd22 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -12,4 +12,6 @@ python: path: . sphinx: - configuration: docs/source/conf.py \ No newline at end of file + configuration: docs/source/conf.py + +formats: all From e21492b7d380a8ed006abf70c04583c661900f12 Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Thu, 14 Dec 2023 15:55:22 -0600 Subject: [PATCH 191/238] oauth: add support for alternate oauth client --- ytmusicapi/auth/oauth.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index 6a06e480..35e93af4 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -30,13 +30,35 @@ def is_custom_oauth(headers: CaseInsensitiveDict) -> bool: class YTMusicOAuth: """OAuth implementation for YouTube Music based on YouTube TV""" - def __init__(self, session: requests.Session, proxies: Dict = None): + def __init__(self, + session: requests.Session, + proxies: Dict = None, + *, + oauth_client_id: Optional[str] = None, + oauth_client_secret: Optional[str] = None): + """ + :param session: Session instance used for authorization requests. + :param proxies: Optional. Proxy configuration to be used by the session. + :param oauth_client_id: Optional. Keyword Only. Can be supplied to change the oauth client for token + retrieval and refreshing. Requires oauth_client_secret also be provided. + :param oauth_client_secret: Optional. Keyword Only. Used in conjunction with oauth_client_id to switch + underlying oauth client. + """ + + if not isinstance(oauth_client_id, type(oauth_client_secret)): + raise KeyError( + 'Alternate oauth credential usage requires an id AND a secret. Pass both or neither.' + ) + + self.oauth_id = oauth_client_id if oauth_client_id else OAUTH_CLIENT_ID + self.oauth_secret = oauth_client_secret if oauth_client_secret else OAUTH_CLIENT_SECRET + self._session = session if proxies: self._session.proxies.update(proxies) def _send_request(self, url, data) -> requests.Response: - data.update({"client_id": OAUTH_CLIENT_ID}) + data.update({"client_id": self.oauth_id}) headers = {"User-Agent": OAUTH_USER_AGENT} return self._session.post(url, data, headers=headers) @@ -55,7 +77,7 @@ def get_token_from_code(self, device_code: str) -> Dict: response = self._send_request( OAUTH_TOKEN_URL, data={ - "client_secret": OAUTH_CLIENT_SECRET, + "client_secret": self.oauth_secret, "grant_type": "http://oauth.net/grant_type/device/1.0", "code": device_code, }, @@ -66,7 +88,7 @@ def refresh_token(self, refresh_token: str) -> Dict: response = self._send_request( OAUTH_TOKEN_URL, data={ - "client_secret": OAUTH_CLIENT_SECRET, + "client_secret": self.oauth_secret, "grant_type": "refresh_token", "refresh_token": refresh_token, }, From b9be59de50c93739ce8e979162c5ac96abb00ea3 Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Sat, 16 Dec 2023 15:55:06 -0600 Subject: [PATCH 192/238] get_album_browse_id: fix depreciated escape sequence error && add performance increase --- ytmusicapi/mixins/browsing.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 61cf9ab9..fe4dbf26 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -332,7 +332,7 @@ def get_user_playlists(self, channelId: str, params: str) -> List[Dict]: return user_playlists - def get_album_browse_id(self, audioPlaylistId: str) -> str: + def get_album_browse_id(self, audioPlaylistId: str) -> str | None: """ Get an album's browseId based on its audioPlaylistId @@ -341,11 +341,11 @@ def get_album_browse_id(self, audioPlaylistId: str) -> str: """ params = {"list": audioPlaylistId} response = self._send_get_request(YTM_DOMAIN + "/playlist", params) - matches = re.search(r"\"MPRE.+?\"", response.text.encode("utf8").decode("unicode_escape")) - browse_id = None - if matches: - browse_id = matches.group().strip('"') - return browse_id + + if (start := response.text.find('MPREb_')) == -1: + return None + + return response.text[start:(response.text.find('"', start) - 1)] def get_album(self, browseId: str) -> Dict: """ From b2c078df636fd82548af8359cb0491cd23ccdccf Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Sat, 16 Dec 2023 15:58:00 -0600 Subject: [PATCH 193/238] get_charts: fix index errors on authorized song chart missing --- tests/test.py | 3 ++- ytmusicapi/mixins/explore.py | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test.py b/tests/test.py index 9aff93df..f69f3d48 100644 --- a/tests/test.py +++ b/tests/test.py @@ -284,7 +284,8 @@ def test_get_mood_playlists(self): def test_get_charts(self): charts = self.yt_oauth.get_charts() - self.assertEqual(len(charts), 4) + # songs section appears to be removed currently (US) + self.assertGreaterEqual(len(charts), 3) charts = self.yt.get_charts(country="US") self.assertEqual(len(charts), 5) charts = self.yt.get_charts(country="BE") diff --git a/ytmusicapi/mixins/explore.py b/ytmusicapi/mixins/explore.py index 8690af73..9b554ef3 100644 --- a/ytmusicapi/mixins/explore.py +++ b/ytmusicapi/mixins/explore.py @@ -95,7 +95,7 @@ def get_charts(self, country: str = 'ZZ') -> Dict: Global charts have no Trending section, US charts have an extra Genres section with some Genre charts. :param country: ISO 3166-1 Alpha-2 country code. Default: ZZ = Global - :return: Dictionary containing chart songs (only if authenticated), chart videos, chart artists and + :return: Dictionary containing chart songs (only if authenticated and available), chart videos, chart artists and trending videos. Example:: @@ -209,9 +209,13 @@ def get_charts(self, country: str = 'ZZ') -> Dict: ])) charts_categories = ['videos', 'artists'] - has_songs = bool(self.auth) has_genres = country == 'US' has_trending = country != 'ZZ' + # songs section appears to no longer exist, extra length check avoids + # index errors and will still include songs if the feature is added back + has_songs = bool( + self.auth) and len(results) - 1 > (len(charts_categories) + has_genres + has_trending) + if has_songs: charts_categories.insert(0, 'songs') if has_genres: From b60af9cc25cc39f5f8759a63a323a658b5389d0c Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Sat, 16 Dec 2023 16:07:07 -0600 Subject: [PATCH 194/238] add: set_album_save as a convenience/alias/findable method for album rate_playlist --- tests/test.py | 7 +++++++ ytmusicapi/mixins/library.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/tests/test.py b/tests/test.py index f69f3d48..e4a9a2bc 100644 --- a/tests/test.py +++ b/tests/test.py @@ -417,6 +417,13 @@ def test_rate_playlist(self): "INDIFFERENT") self.assertIn("actions", response) + def test_save_album(self): + response = self.yt_auth.set_album_save("OLAK5uy_kCxQGgn8Ayb-8VNSs3Ok7qdcKe5PJy6ZQ") + self.assertIn("actions", response) + response = self.yt_auth.set_album_save("OLAK5uy_kCxQGgn8Ayb-8VNSs3Ok7qdcKe5PJy6ZQ", + saved=False) + self.assertIn("actions", response) + def test_subscribe_artists(self): self.yt_auth.subscribe_artists(["UCUDVBtnOQi4c7E8jebpjc9Q", "UCiMhD4jzUqG-IgPzUmmytRQ"]) self.yt_auth.unsubscribe_artists(["UCUDVBtnOQi4c7E8jebpjc9Q", "UCiMhD4jzUqG-IgPzUmmytRQ"]) diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index 71b01bba..c2652934 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -267,6 +267,25 @@ def rate_song(self, videoId: str, rating: str = 'INDIFFERENT') -> Dict: return self._send_request(endpoint, body) + def set_album_save(self, audioPlaylistId: str, saved=True): + """ + Edit the save status of an album in the user's library. Findable convenience/alias + method for album portion of rate_playlist. + + :param audioPlaylistId: audioPlaylistId (starts with "OLAKuy_") for the target album + :param saved: Boolean. The resulting saved state of the album. (Default = True) + + :return: Full response + """ + + self._check_auth() + body = {'target': {'playlistId': audioPlaylistId}} + endpoint = prepare_like_endpoint('LIKE' if saved else 'INDIFFERENT') + if endpoint is None: + return + + return self._send_request(endpoint, body) + def edit_song_library_status(self, feedbackTokens: List[str] = None) -> Dict: """ Adds or removes a song from your library depending on the token provided. From 91949b54031bd4d9c95ccca4ff31906a940bf1a7 Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Sat, 16 Dec 2023 16:18:38 -0600 Subject: [PATCH 195/238] fix: externalize library dependent or regional arguments to test.cfg --- tests/test.cfg.example | 13 ++++++++++ tests/test.py | 57 ++++++++++++++++++++++++++---------------- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/tests/test.cfg.example b/tests/test.cfg.example index 25cf53ef..e0a9c901 100644 --- a/tests/test.cfg.example +++ b/tests/test.cfg.example @@ -9,8 +9,21 @@ oauth_code = {"device_code":"","user_code":"","expires_in":1800,"interval":5,"ve oauth_token = {"access_token":"","expires_in":1000,"refresh_token":"","scope":"https://www.googleapis.com/auth/youtube","token_type":"Bearer"} headers_raw = raw_headers_pasted_from_browser +[queries] +uploads_songs = query_gives_gt_20_songs +library_any = query_gives_gt_5_results +library_songs = query_gives_gt_10_songs +library_albums = query_gives_gte_5_albums +library_artists = query_gives_gte_1_artist +library_playlists = query_gives_gte_1_playlist + [playlists] own = owned_playlist_id +own_length = number_of_tracks_in_playlist + +[albums] +album_browse_id = sample_id_of_regionally_available_album +album_track_length = available_album_length [uploads] file = song_in_tests_directory.mp3 diff --git a/tests/test.py b/tests/test.py index e4a9a2bc..2cfccc00 100644 --- a/tests/test.py +++ b/tests/test.py @@ -135,24 +135,37 @@ def test_search_uploads(self): self.assertRaises( Exception, self.yt.search, - "audiomachine", + config['queries']['uploads_songs'], filter="songs", scope="uploads", limit=40, ) - results = self.yt_auth.search("audiomachine", scope="uploads", limit=40) + results = self.yt_auth.search(config['queries']['uploads_songs'], + scope="uploads", + limit=40) self.assertGreater(len(results), 20) def test_search_library(self): - results = self.yt_oauth.search("garrix", scope="library") + results = self.yt_oauth.search(config['queries']['library_any'], scope="library") self.assertGreater(len(results), 5) - results = self.yt_auth.search("bergersen", filter="songs", scope="library", limit=40) + results = self.yt_auth.search(config['queries']['library_songs'], + filter="songs", + scope="library", + limit=40) self.assertGreater(len(results), 10) - results = self.yt_auth.search("garrix", filter="albums", scope="library", limit=40) + results = self.yt_auth.search(config['queries']['library_albums'], + filter="albums", + scope="library", + limit=40) self.assertGreaterEqual(len(results), 4) - results = self.yt_auth.search("garrix", filter="artists", scope="library", limit=40) + results = self.yt_auth.search(config['queries']['library_artists'], + filter="artists", + scope="library", + limit=40) self.assertGreaterEqual(len(results), 1) - results = self.yt_auth.search("garrix", filter="playlists", scope="library") + results = self.yt_auth.search(config['queries']['library_playlists'], + filter="playlists", + scope="library") self.assertGreaterEqual(len(results), 1) self.assertRaises(Exception, self.yt_auth.search, @@ -215,7 +228,7 @@ def test_get_album(self): self.assertGreaterEqual(len(results), 9) self.assertTrue(results["tracks"][0]["isExplicit"]) self.assertIn("feedbackTokens", results["tracks"][0]) - self.assertEqual(len(results["other_versions"]), 2) + self.assertGreaterEqual(len(results["other_versions"]), 1) # appears to be regional results = self.yt.get_album("MPREb_BQZvl3BFGay") self.assertEqual(len(results["tracks"]), 7) self.assertEqual(len(results["tracks"][0]["artists"]), 1) @@ -244,8 +257,8 @@ def test_get_lyrics(self): self.assertRaises(Exception, self.yt.get_lyrics, playlist["lyrics"]) def test_get_signatureTimestamp(self): - signatureTimestamp = self.yt.get_signatureTimestamp() - self.assertIsNotNone(signatureTimestamp) + signature_timestamp = self.yt.get_signatureTimestamp() + self.assertIsNotNone(signature_timestamp) def test_set_tasteprofile(self): self.assertRaises(Exception, self.yt.set_tasteprofile, "not an artist") @@ -306,12 +319,12 @@ def test_get_watch_playlist(self): self.assertGreater(len(playlist["tracks"]), 45) playlist = self.yt_oauth.get_watch_playlist("UoAf_y9Ok4k") # private track self.assertGreaterEqual(len(playlist["tracks"]), 25) - playlist = self.yt.get_watch_playlist( - playlistId="OLAK5uy_lKgoGvlrWhX0EIPavQUXxyPed8Cj38AWc", shuffle=True) - self.assertEqual(len(playlist["tracks"]), 12) + playlist = self.yt.get_watch_playlist(playlistId=config['albums']['album_browse_id'], + shuffle=True) + self.assertEqual(len(playlist["tracks"]), config.getint('albums', 'album_track_length')) playlist = self.yt_brand.get_watch_playlist(playlistId=config["playlists"]["own"], shuffle=True) - self.assertEqual(len(playlist["tracks"]), 4) + self.assertEqual(len(playlist["tracks"]), config.getint('playlists', 'own_length')) ################ # LIBRARY @@ -485,15 +498,15 @@ def test_edit_playlist(self): # end to end test adding playlist, adding item, deleting item, deleting playlist def test_end2end(self): - playlistId = self.yt_brand.create_playlist( + playlist_id = self.yt_brand.create_playlist( "test", "test description", source_playlist="OLAK5uy_lGQfnMNGvYCRdDq9ZLzJV2BJL2aHQsz9Y", ) - self.assertEqual(len(playlistId), 34, "Playlist creation failed") - self.yt_brand.edit_playlist(playlistId, addToTop=True) + self.assertEqual(len(playlist_id), 34, "Playlist creation failed") + self.yt_brand.edit_playlist(playlist_id, addToTop=True) response = self.yt_brand.add_playlist_items( - playlistId, + playlist_id, [sample_video, sample_video], source_playlist="OLAK5uy_nvjTE32aFYdFN7HCyMv3cGqD3wqBb4Jow", duplicates=True, @@ -501,12 +514,12 @@ def test_end2end(self): self.assertEqual(response["status"], "STATUS_SUCCEEDED", "Adding playlist item failed") self.assertGreater(len(response["playlistEditResults"]), 0, "Adding playlist item failed") time.sleep(2) - self.yt_brand.edit_playlist(playlistId, addToTop=False) - playlist = self.yt_brand.get_playlist(playlistId, related=True) + self.yt_brand.edit_playlist(playlist_id, addToTop=False) + playlist = self.yt_brand.get_playlist(playlist_id, related=True) self.assertEqual(len(playlist["tracks"]), 46, "Getting playlist items failed") - response = self.yt_brand.remove_playlist_items(playlistId, playlist["tracks"]) + response = self.yt_brand.remove_playlist_items(playlist_id, playlist["tracks"]) self.assertEqual(response, "STATUS_SUCCEEDED", "Playlist item removal failed") - self.yt_brand.delete_playlist(playlistId) + self.yt_brand.delete_playlist(playlist_id) ############### # UPLOADS From 6c94c80086a2eabd90177365dae309b8bb473af5 Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Sat, 16 Dec 2023 16:26:24 -0600 Subject: [PATCH 196/238] add: integrate alt_oauth into YTMusic && add: token dict support for auth parameter --- tests/test.py | 23 +++++++++++++++++------ ytmusicapi/auth/headers.py | 24 +++++++++++++++++------- ytmusicapi/ytmusic.py | 36 ++++++++++++++++++++++++++++-------- 3 files changed, 62 insertions(+), 21 deletions(-) diff --git a/tests/test.py b/tests/test.py index 2cfccc00..b8395d2a 100644 --- a/tests/test.py +++ b/tests/test.py @@ -10,6 +10,7 @@ from ytmusicapi.setup import main, setup # noqa: E402 from ytmusicapi.ytmusic import YTMusic # noqa: E402 +from ytmusicapi.constants import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET def get_resource(file: str) -> str: @@ -27,6 +28,8 @@ def get_resource(file: str) -> str: headers_oauth = get_resource(config["auth"]["headers_oauth"]) headers_browser = get_resource(config["auth"]["headers_file"]) +alt_oauth_args = {'oauth_client_id': OAUTH_CLIENT_ID, 'oauth_client_secret': OAUTH_CLIENT_SECRET} + class TestYTMusic(unittest.TestCase): @@ -37,6 +40,7 @@ def setUpClass(cls): assert isinstance(yt, YTMusic) cls.yt = YTMusic() cls.yt_oauth = YTMusic(headers_oauth) + cls.yt_alt_oauth = YTMusic(headers_browser, alt_oauth=alt_oauth_args) cls.yt_auth = YTMusic(headers_browser, location="GB") cls.yt_brand = YTMusic(config["auth"]["headers"], config["auth"]["brand_account"]) cls.yt_empty = YTMusic(config["auth"]["headers_empty"], @@ -67,7 +71,14 @@ def test_setup_oauth(self, session_mock, json_mock): json_mock.side_effect = None with open(headers_oauth, mode="r", encoding="utf8") as headers: string_headers = headers.read() - self.yt_oauth = YTMusic(string_headers) + + self.yt_oauth = YTMusic(string_headers) + with self.subTest(): + # ensure client works/ignores alt if browser credentials passed as auth + self.assertFalse(self.yt_alt_oauth.is_alt_oauth) + # oauth token dict entry and alt + self.yt_alt_oauth = YTMusic(json.loads(string_headers), alt_oauth=alt_oauth_args) + self.assertTrue(self.yt_alt_oauth.is_alt_oauth) ############### # BROWSING @@ -148,10 +159,10 @@ def test_search_uploads(self): def test_search_library(self): results = self.yt_oauth.search(config['queries']['library_any'], scope="library") self.assertGreater(len(results), 5) - results = self.yt_auth.search(config['queries']['library_songs'], - filter="songs", - scope="library", - limit=40) + results = self.yt_alt_oauth.search(config['queries']['library_songs'], + filter="songs", + scope="library", + limit=40) self.assertGreater(len(results), 10) results = self.yt_auth.search(config['queries']['library_albums'], filter="albums", @@ -243,7 +254,7 @@ def test_get_song(self): def test_get_song_related_content(self): song = self.yt_oauth.get_watch_playlist(sample_video) - song = self.yt_oauth.get_song_related(song["related"]) + song = self.yt_alt_oauth.get_song_related(song["related"]) self.assertGreaterEqual(len(song), 5) def test_get_lyrics(self): diff --git a/ytmusicapi/auth/headers.py b/ytmusicapi/auth/headers.py index 08279504..5901e8a9 100644 --- a/ytmusicapi/auth/headers.py +++ b/ytmusicapi/auth/headers.py @@ -20,16 +20,26 @@ def load_headers_file(auth: str) -> Dict: return input_json -def prepare_headers( - session: requests.Session, - proxies: Optional[Dict] = None, - input_dict: Optional[CaseInsensitiveDict] = None, -) -> Dict: +def prepare_headers(auth_session: requests.Session | YTMusicOAuth, + proxies: Optional[Dict] = None, + input_dict: Optional[CaseInsensitiveDict] = None) -> Dict: + """ + + :param auth_session: Either the session to be used when creating a YTMusicOAuth + instance or the YTMusicOAuth instance itself. + :param proxies: Optional. Proxy configuration parameters passed to session. + Ignored if YTMusicOAuth instance passed to function. + :param input_dict: Optional. Either completed browser auth headers or oauth token. + """ if input_dict: if is_oauth(input_dict): - oauth = YTMusicOAuth(session, proxies) - headers = oauth.load_headers(dict(input_dict), input_dict['filepath']) + if isinstance(auth_session, YTMusicOAuth): + oauth = auth_session + else: + oauth = YTMusicOAuth(auth_session, proxies) + + headers = oauth.load_headers(dict(input_dict), input_dict.get('filepath')) elif is_browser(input_dict): headers = input_dict diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 29bc187d..d246bfa0 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -3,7 +3,7 @@ import os from functools import partial from contextlib import suppress -from typing import Dict +from typing import Dict, Optional from requests.structures import CaseInsensitiveDict from ytmusicapi.auth.headers import load_headers_file, prepare_headers @@ -28,16 +28,17 @@ class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin """ def __init__(self, - auth: str = None, + auth: Optional[str | dict] = None, user: str = None, requests_session=True, proxies: dict = None, language: str = 'en', - location: str = ''): + location: str = '', + alt_oauth: Optional[dict] = None): """ Create a new instance to interact with YouTube Music. - :param auth: Optional. Provide a string or path to file. + :param auth: Optional. Provide a string, path to file, or oauth token dict. Authentication credentials are needed to manage your library. See :py:func:`setup` for how to fill in the correct credentials. Default: A default header is used without authentication. @@ -68,10 +69,14 @@ def __init__(self, :param location: Optional. Can be used to change the location of the user. No location will be set by default. This means it is determined by the server. Available languages can be checked in the FAQ. + :param alt_oauth: Optional. Used to specify a different oauth client id and secret to be + used for authentication flow. Should contain both oauth_client_id + and oauth_client_secret keys when provided. """ self.auth = auth self.input_dict = None self.is_oauth_auth = False + self.alt_oauth = None if isinstance(requests_session, requests.Session): self._session = requests_session @@ -87,12 +92,23 @@ def __init__(self, # value from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L502 self.cookies = {'SOCS': 'CAI'} if self.auth is not None: - input_json = load_headers_file(self.auth) - self.input_dict = CaseInsensitiveDict(input_json) - self.input_dict['filepath'] = self.auth + if isinstance(self.auth, str): + input_json = load_headers_file(self.auth) + self.input_dict = CaseInsensitiveDict(input_json) + self.input_dict['filepath'] = self.auth + + elif isinstance(self.auth, dict): + self.input_dict = self.auth + self.is_oauth_auth = is_oauth(self.input_dict) - self.headers = prepare_headers(self._session, proxies, self.input_dict) + # use custom oauth client parameters if provided + if alt_oauth and isinstance(alt_oauth, dict): + self.alt_oauth = YTMusicOAuth(self._session, proxies, **alt_oauth) + # custom oauth passed in place of session, kwarg used as proxies are skipped + self.headers = prepare_headers(self.alt_oauth, input_dict=self.input_dict) + else: + self.headers = prepare_headers(self._session, proxies, self.input_dict) if 'x-goog-visitor-id' not in self.headers: self.headers.update(get_visitor_id(self._send_get_request)) @@ -132,6 +148,10 @@ def __init__(self, except KeyError: raise Exception("Your cookie is missing the required value __Secure-3PAPISID") + @property + def is_alt_oauth(self): + return self.alt_oauth is not None and self.is_oauth_auth + def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") -> Dict: if self.is_oauth_auth: From 742f5842fa554e60e6b718780eca4d3577d38127 Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Mon, 18 Dec 2023 12:53:31 -0600 Subject: [PATCH 197/238] unadd: set_album_save. unneeded duplicate to rate_playlist --- tests/test.py | 7 ------- ytmusicapi/mixins/library.py | 19 ------------------- 2 files changed, 26 deletions(-) diff --git a/tests/test.py b/tests/test.py index b8395d2a..0fea6025 100644 --- a/tests/test.py +++ b/tests/test.py @@ -441,13 +441,6 @@ def test_rate_playlist(self): "INDIFFERENT") self.assertIn("actions", response) - def test_save_album(self): - response = self.yt_auth.set_album_save("OLAK5uy_kCxQGgn8Ayb-8VNSs3Ok7qdcKe5PJy6ZQ") - self.assertIn("actions", response) - response = self.yt_auth.set_album_save("OLAK5uy_kCxQGgn8Ayb-8VNSs3Ok7qdcKe5PJy6ZQ", - saved=False) - self.assertIn("actions", response) - def test_subscribe_artists(self): self.yt_auth.subscribe_artists(["UCUDVBtnOQi4c7E8jebpjc9Q", "UCiMhD4jzUqG-IgPzUmmytRQ"]) self.yt_auth.unsubscribe_artists(["UCUDVBtnOQi4c7E8jebpjc9Q", "UCiMhD4jzUqG-IgPzUmmytRQ"]) diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index c2652934..71b01bba 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -267,25 +267,6 @@ def rate_song(self, videoId: str, rating: str = 'INDIFFERENT') -> Dict: return self._send_request(endpoint, body) - def set_album_save(self, audioPlaylistId: str, saved=True): - """ - Edit the save status of an album in the user's library. Findable convenience/alias - method for album portion of rate_playlist. - - :param audioPlaylistId: audioPlaylistId (starts with "OLAKuy_") for the target album - :param saved: Boolean. The resulting saved state of the album. (Default = True) - - :return: Full response - """ - - self._check_auth() - body = {'target': {'playlistId': audioPlaylistId}} - endpoint = prepare_like_endpoint('LIKE' if saved else 'INDIFFERENT') - if endpoint is None: - return - - return self._send_request(endpoint, body) - def edit_song_library_status(self, feedbackTokens: List[str] = None) -> Dict: """ Adds or removes a song from your library depending on the token provided. From 8f7970b2643379c26f2cfcc739df69b31baabd7b Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 18 Dec 2023 20:34:36 +0100 Subject: [PATCH 198/238] get_library_songs, get_library_upload_songs: fix empty result for libraries containing exactly 1 song --- ytmusicapi/mixins/uploads.py | 7 +++++-- ytmusicapi/parsers/library.py | 9 ++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index e63bb19d..924dc9d3 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -7,12 +7,14 @@ from ytmusicapi.helpers import * from ytmusicapi.navigation import * from ytmusicapi.continuations import get_continuations -from ytmusicapi.parsers.library import parse_library_albums, parse_library_artists, get_library_contents +from ytmusicapi.parsers.library import parse_library_albums, parse_library_artists, get_library_contents, \ + pop_songs_random_mix from ytmusicapi.parsers.albums import parse_album_header from ytmusicapi.parsers.uploads import parse_uploaded_items class UploadsMixin: + def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[Dict]: """ Returns a list of uploaded songs @@ -44,9 +46,10 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D body["params"] = prepare_order_params(order) response = self._send_request(endpoint, body) results = get_library_contents(response, MUSIC_SHELF) + pop_songs_random_mix(results['contents']) if results is None: return [] - songs = parse_uploaded_items(results['contents'][1:]) + songs = parse_uploaded_items(results['contents']) if 'continuations' in results: request_func = lambda additionalParams: self._send_request( diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index 1fa35c13..48249f0f 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -75,11 +75,18 @@ def parse_library_artists(response, request_func, limit): return artists +def pop_songs_random_mix(results) -> None: + """remove the random mix that conditionally appears at the start of library songs""" + if len(results) >= 2: + results.pop(0) + + def parse_library_songs(response): results = get_library_contents(response, MUSIC_SHELF) + pop_songs_random_mix(results['contents']) return { 'results': results, - 'parsed': parse_playlist_items(results['contents'][1:]) if results else results + 'parsed': parse_playlist_items(results['contents']) if results else results } From bd7a6b452d8dd80e3f82db9b4c2c45733121acb4 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 18 Dec 2023 21:02:29 +0100 Subject: [PATCH 199/238] fix poop_songs_random_mix --- ytmusicapi/mixins/uploads.py | 2 +- ytmusicapi/parsers/library.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index 924dc9d3..c4bbcf4a 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -46,7 +46,7 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D body["params"] = prepare_order_params(order) response = self._send_request(endpoint, body) results = get_library_contents(response, MUSIC_SHELF) - pop_songs_random_mix(results['contents']) + pop_songs_random_mix(results) if results is None: return [] songs = parse_uploaded_items(results['contents']) diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index 48249f0f..1c6e07d6 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -77,13 +77,14 @@ def parse_library_artists(response, request_func, limit): def pop_songs_random_mix(results) -> None: """remove the random mix that conditionally appears at the start of library songs""" - if len(results) >= 2: - results.pop(0) + if results: + if len(results['contents']) >= 2: + results['contents'].pop(0) def parse_library_songs(response): results = get_library_contents(response, MUSIC_SHELF) - pop_songs_random_mix(results['contents']) + pop_songs_random_mix(results) return { 'results': results, 'parsed': parse_playlist_items(results['contents']) if results else results From 67d8b7c8accded1ee45a2e67695514bb766cf66b Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 18 Dec 2023 21:06:21 +0100 Subject: [PATCH 200/238] fix broken test playlist id --- tests/test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test.py b/tests/test.py index 9aff93df..cb6161b1 100644 --- a/tests/test.py +++ b/tests/test.py @@ -306,7 +306,7 @@ def test_get_watch_playlist(self): playlist = self.yt_oauth.get_watch_playlist("UoAf_y9Ok4k") # private track self.assertGreaterEqual(len(playlist["tracks"]), 25) playlist = self.yt.get_watch_playlist( - playlistId="OLAK5uy_lKgoGvlrWhX0EIPavQUXxyPed8Cj38AWc", shuffle=True) + playlistId="OLAK5uy_kt7zOXlNCGsYFEdNc5Pvnr4JFfMkspmc8", shuffle=True) self.assertEqual(len(playlist["tracks"]), 12) playlist = self.yt_brand.get_watch_playlist(playlistId=config["playlists"]["own"], shuffle=True) From 10241c7ee44090af30937e2a7a45aaf83f9bc4a4 Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Mon, 18 Dec 2023 17:58:21 -0600 Subject: [PATCH 201/238] fix: dataclass implementation of credentials --- tests/test.py | 21 ++++++++++---------- ytmusicapi/auth/headers.py | 21 ++++++++++---------- ytmusicapi/auth/oauth.py | 33 ++++++++++++++++---------------- ytmusicapi/ytmusic.py | 39 +++++++++++++++++++------------------- 4 files changed, 58 insertions(+), 56 deletions(-) diff --git a/tests/test.py b/tests/test.py index 0fea6025..e9bfb3aa 100644 --- a/tests/test.py +++ b/tests/test.py @@ -9,7 +9,7 @@ from requests import Response from ytmusicapi.setup import main, setup # noqa: E402 -from ytmusicapi.ytmusic import YTMusic # noqa: E402 +from ytmusicapi.ytmusic import YTMusic, OAuthCredentials # noqa: E402 from ytmusicapi.constants import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET @@ -28,7 +28,7 @@ def get_resource(file: str) -> str: headers_oauth = get_resource(config["auth"]["headers_oauth"]) headers_browser = get_resource(config["auth"]["headers_file"]) -alt_oauth_args = {'oauth_client_id': OAUTH_CLIENT_ID, 'oauth_client_secret': OAUTH_CLIENT_SECRET} +alt_oauth_creds = OAuthCredentials(**{'client_id': OAUTH_CLIENT_ID, 'client_secret': OAUTH_CLIENT_SECRET}) class TestYTMusic(unittest.TestCase): @@ -40,7 +40,7 @@ def setUpClass(cls): assert isinstance(yt, YTMusic) cls.yt = YTMusic() cls.yt_oauth = YTMusic(headers_oauth) - cls.yt_alt_oauth = YTMusic(headers_browser, alt_oauth=alt_oauth_args) + cls.yt_alt_oauth = YTMusic(headers_browser, oauth_credentials=alt_oauth_creds) cls.yt_auth = YTMusic(headers_browser, location="GB") cls.yt_brand = YTMusic(config["auth"]["headers"], config["auth"]["brand_account"]) cls.yt_empty = YTMusic(config["auth"]["headers_empty"], @@ -71,14 +71,14 @@ def test_setup_oauth(self, session_mock, json_mock): json_mock.side_effect = None with open(headers_oauth, mode="r", encoding="utf8") as headers: string_headers = headers.read() + self.yt_oauth = YTMusic(string_headers) - self.yt_oauth = YTMusic(string_headers) - with self.subTest(): - # ensure client works/ignores alt if browser credentials passed as auth - self.assertFalse(self.yt_alt_oauth.is_alt_oauth) - # oauth token dict entry and alt - self.yt_alt_oauth = YTMusic(json.loads(string_headers), alt_oauth=alt_oauth_args) - self.assertTrue(self.yt_alt_oauth.is_alt_oauth) + def test_alt_oauth(self): + # ensure client works/ignores alt if browser credentials passed as auth + self.assertFalse(self.yt_alt_oauth.is_alt_oauth) + # oauth token dict entry and alt + self.yt_alt_oauth = YTMusic(json.loads(config['auth']['oauth_token']), oauth_credentials=alt_oauth_creds) + self.assertTrue(self.yt_alt_oauth.is_alt_oauth) ############### # BROWSING @@ -501,6 +501,7 @@ def test_edit_playlist(self): self.assertEqual(response, "STATUS_SUCCEEDED", "Playlist edit failed") # end to end test adding playlist, adding item, deleting item, deleting playlist + @unittest.skip('You are creating too many playlists') def test_end2end(self): playlist_id = self.yt_brand.create_playlist( "test", diff --git a/ytmusicapi/auth/headers.py b/ytmusicapi/auth/headers.py index 5901e8a9..289b40df 100644 --- a/ytmusicapi/auth/headers.py +++ b/ytmusicapi/auth/headers.py @@ -6,7 +6,7 @@ from requests.structures import CaseInsensitiveDict from ytmusicapi.auth.browser import is_browser -from ytmusicapi.auth.oauth import YTMusicOAuth, is_oauth, is_custom_oauth +from ytmusicapi.auth.oauth import OAuthCredentials, YTMusicOAuth, is_oauth, is_custom_oauth from ytmusicapi.helpers import initialize_headers @@ -14,31 +14,32 @@ def load_headers_file(auth: str) -> Dict: if os.path.isfile(auth): with open(auth) as json_file: input_json = json.load(json_file) + input_json['filepath'] = auth else: input_json = json.loads(auth) return input_json -def prepare_headers(auth_session: requests.Session | YTMusicOAuth, +def prepare_headers(session: requests.Session, proxies: Optional[Dict] = None, - input_dict: Optional[CaseInsensitiveDict] = None) -> Dict: + input_dict: Optional[CaseInsensitiveDict] = None, + oauth_credentials: Optional[OAuthCredentials] = None + ) -> Dict: """ - :param auth_session: Either the session to be used when creating a YTMusicOAuth + :param session: Either the session to be used when creating a YTMusicOAuth instance or the YTMusicOAuth instance itself. :param proxies: Optional. Proxy configuration parameters passed to session. Ignored if YTMusicOAuth instance passed to function. :param input_dict: Optional. Either completed browser auth headers or oauth token. + :param oauth_credentials: Optional. An instance of OAuthCredentials to use for authentication flow. + (Default = None) """ - if input_dict: + if input_dict: if is_oauth(input_dict): - if isinstance(auth_session, YTMusicOAuth): - oauth = auth_session - else: - oauth = YTMusicOAuth(auth_session, proxies) - + oauth = YTMusicOAuth(session, proxies, oauth_credentials=oauth_credentials) headers = oauth.load_headers(dict(input_dict), input_dict.get('filepath')) elif is_browser(input_dict): diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index 35e93af4..2e436c81 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -2,6 +2,9 @@ import time import webbrowser from typing import Dict, Optional +from dataclasses import dataclass +from os import environ as env + import requests from requests.structures import CaseInsensitiveDict @@ -27,6 +30,12 @@ def is_custom_oauth(headers: CaseInsensitiveDict) -> bool: return "authorization" in headers and headers["authorization"].startswith("Bearer ") +@dataclass +class OAuthCredentials: + client_id: Optional[str] = env.get('YTMA_OAUTH_ID', OAUTH_CLIENT_ID) + client_secret: Optional[str] = env.get('YTMA_OAUTH_SECRET', OAUTH_CLIENT_SECRET) + + class YTMusicOAuth: """OAuth implementation for YouTube Music based on YouTube TV""" @@ -34,31 +43,23 @@ def __init__(self, session: requests.Session, proxies: Dict = None, *, - oauth_client_id: Optional[str] = None, - oauth_client_secret: Optional[str] = None): + oauth_credentials: Optional[OAuthCredentials] = None): """ :param session: Session instance used for authorization requests. :param proxies: Optional. Proxy configuration to be used by the session. - :param oauth_client_id: Optional. Keyword Only. Can be supplied to change the oauth client for token - retrieval and refreshing. Requires oauth_client_secret also be provided. - :param oauth_client_secret: Optional. Keyword Only. Used in conjunction with oauth_client_id to switch - underlying oauth client. + :param oauth_credentials: Optional. Instance of OAuthCredentials containing + a client id and secret used for authentication flow. Providing will override + those found in constants or provided as environmental variables. """ - if not isinstance(oauth_client_id, type(oauth_client_secret)): - raise KeyError( - 'Alternate oauth credential usage requires an id AND a secret. Pass both or neither.' - ) - - self.oauth_id = oauth_client_id if oauth_client_id else OAUTH_CLIENT_ID - self.oauth_secret = oauth_client_secret if oauth_client_secret else OAUTH_CLIENT_SECRET + self.credentials = oauth_credentials if oauth_credentials else OAuthCredentials() self._session = session if proxies: self._session.proxies.update(proxies) def _send_request(self, url, data) -> requests.Response: - data.update({"client_id": self.oauth_id}) + data.update({"client_id": self.credentials.client_id}) headers = {"User-Agent": OAUTH_USER_AGENT} return self._session.post(url, data, headers=headers) @@ -77,7 +78,7 @@ def get_token_from_code(self, device_code: str) -> Dict: response = self._send_request( OAUTH_TOKEN_URL, data={ - "client_secret": self.oauth_secret, + "client_secret": self.credentials.client_secret, "grant_type": "http://oauth.net/grant_type/device/1.0", "code": device_code, }, @@ -88,7 +89,7 @@ def refresh_token(self, refresh_token: str) -> Dict: response = self._send_request( OAUTH_TOKEN_URL, data={ - "client_secret": self.oauth_secret, + "client_secret": self.credentials.client_secret, "grant_type": "refresh_token", "refresh_token": refresh_token, }, diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index d246bfa0..a3dbc05e 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -16,7 +16,7 @@ from ytmusicapi.mixins.library import LibraryMixin from ytmusicapi.mixins.playlists import PlaylistsMixin from ytmusicapi.mixins.uploads import UploadsMixin -from ytmusicapi.auth.oauth import YTMusicOAuth, is_oauth +from ytmusicapi.auth.oauth import OAuthCredentials, YTMusicOAuth, is_oauth class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, @@ -34,7 +34,7 @@ def __init__(self, proxies: dict = None, language: str = 'en', location: str = '', - alt_oauth: Optional[dict] = None): + oauth_credentials: Optional[OAuthCredentials] = None): """ Create a new instance to interact with YouTube Music. @@ -69,14 +69,16 @@ def __init__(self, :param location: Optional. Can be used to change the location of the user. No location will be set by default. This means it is determined by the server. Available languages can be checked in the FAQ. - :param alt_oauth: Optional. Used to specify a different oauth client id and secret to be - used for authentication flow. Should contain both oauth_client_id - and oauth_client_secret keys when provided. + :param oauth_credentials: Optional. Used to specify a different oauth client id and secret to be + used for authentication flow. """ self.auth = auth self.input_dict = None self.is_oauth_auth = False - self.alt_oauth = None + if oauth_credentials is not None: + self.oauth_credentials = oauth_credentials + else: + self.oauth_credentials = OAuthCredentials() if isinstance(requests_session, requests.Session): self._session = requests_session @@ -95,20 +97,17 @@ def __init__(self, if isinstance(self.auth, str): input_json = load_headers_file(self.auth) self.input_dict = CaseInsensitiveDict(input_json) - self.input_dict['filepath'] = self.auth - elif isinstance(self.auth, dict): + else: self.input_dict = self.auth self.is_oauth_auth = is_oauth(self.input_dict) + self.is_alt_oauth = self.is_oauth_auth and oauth_credentials is not None - # use custom oauth client parameters if provided - if alt_oauth and isinstance(alt_oauth, dict): - self.alt_oauth = YTMusicOAuth(self._session, proxies, **alt_oauth) - # custom oauth passed in place of session, kwarg used as proxies are skipped - self.headers = prepare_headers(self.alt_oauth, input_dict=self.input_dict) - else: - self.headers = prepare_headers(self._session, proxies, self.input_dict) + self.headers = prepare_headers(self._session, + proxies, + self.input_dict, + oauth_credentials=self.oauth_credentials) if 'x-goog-visitor-id' not in self.headers: self.headers.update(get_visitor_id(self._send_get_request)) @@ -148,14 +147,14 @@ def __init__(self, except KeyError: raise Exception("Your cookie is missing the required value __Secure-3PAPISID") - @property - def is_alt_oauth(self): - return self.alt_oauth is not None and self.is_oauth_auth - def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") -> Dict: if self.is_oauth_auth: - self.headers = prepare_headers(self._session, self.proxies, self.input_dict) + self.headers = prepare_headers(self._session, + self.proxies, + self.input_dict, + oauth_credentials=self.oauth_credentials) + body.update(self.context) params = YTM_PARAMS if self.is_browser_auth: From 9cb8c63d99e5ad45d71e076d55a658fe2c5f040b Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Mon, 18 Dec 2023 23:37:17 -0600 Subject: [PATCH 202/238] minor touch-up: rebuild the entire oauth system --- tests/test.py | 9 +- ytmusicapi/auth/headers.py | 48 +------- ytmusicapi/auth/oauth.py | 237 ++++++++++++++++++++++++++----------- ytmusicapi/setup.py | 31 ++++- ytmusicapi/ytmusic.py | 122 ++++++++++++------- 5 files changed, 281 insertions(+), 166 deletions(-) diff --git a/tests/test.py b/tests/test.py index e9bfb3aa..7d7199c9 100644 --- a/tests/test.py +++ b/tests/test.py @@ -28,7 +28,10 @@ def get_resource(file: str) -> str: headers_oauth = get_resource(config["auth"]["headers_oauth"]) headers_browser = get_resource(config["auth"]["headers_file"]) -alt_oauth_creds = OAuthCredentials(**{'client_id': OAUTH_CLIENT_ID, 'client_secret': OAUTH_CLIENT_SECRET}) +alt_oauth_creds = OAuthCredentials(**{ + 'client_id': OAUTH_CLIENT_ID, + 'client_secret': OAUTH_CLIENT_SECRET +}) class TestYTMusic(unittest.TestCase): @@ -77,7 +80,8 @@ def test_alt_oauth(self): # ensure client works/ignores alt if browser credentials passed as auth self.assertFalse(self.yt_alt_oauth.is_alt_oauth) # oauth token dict entry and alt - self.yt_alt_oauth = YTMusic(json.loads(config['auth']['oauth_token']), oauth_credentials=alt_oauth_creds) + self.yt_alt_oauth = YTMusic(json.loads(config['auth']['oauth_token']), + oauth_credentials=alt_oauth_creds) self.assertTrue(self.yt_alt_oauth.is_alt_oauth) ############### @@ -501,7 +505,6 @@ def test_edit_playlist(self): self.assertEqual(response, "STATUS_SUCCEEDED", "Playlist edit failed") # end to end test adding playlist, adding item, deleting item, deleting playlist - @unittest.skip('You are creating too many playlists') def test_end2end(self): playlist_id = self.yt_brand.create_playlist( "test", diff --git a/ytmusicapi/auth/headers.py b/ytmusicapi/auth/headers.py index 289b40df..6831b8bb 100644 --- a/ytmusicapi/auth/headers.py +++ b/ytmusicapi/auth/headers.py @@ -1,13 +1,6 @@ import json import os -from typing import Dict, Optional - -import requests -from requests.structures import CaseInsensitiveDict - -from ytmusicapi.auth.browser import is_browser -from ytmusicapi.auth.oauth import OAuthCredentials, YTMusicOAuth, is_oauth, is_custom_oauth -from ytmusicapi.helpers import initialize_headers +from typing import Dict def load_headers_file(auth: str) -> Dict: @@ -17,43 +10,4 @@ def load_headers_file(auth: str) -> Dict: input_json['filepath'] = auth else: input_json = json.loads(auth) - return input_json - - -def prepare_headers(session: requests.Session, - proxies: Optional[Dict] = None, - input_dict: Optional[CaseInsensitiveDict] = None, - oauth_credentials: Optional[OAuthCredentials] = None - ) -> Dict: - """ - - :param session: Either the session to be used when creating a YTMusicOAuth - instance or the YTMusicOAuth instance itself. - :param proxies: Optional. Proxy configuration parameters passed to session. - Ignored if YTMusicOAuth instance passed to function. - :param input_dict: Optional. Either completed browser auth headers or oauth token. - :param oauth_credentials: Optional. An instance of OAuthCredentials to use for authentication flow. - (Default = None) - """ - - if input_dict: - if is_oauth(input_dict): - oauth = YTMusicOAuth(session, proxies, oauth_credentials=oauth_credentials) - headers = oauth.load_headers(dict(input_dict), input_dict.get('filepath')) - - elif is_browser(input_dict): - headers = input_dict - - elif is_custom_oauth(input_dict): - headers = input_dict - - else: - raise Exception( - "Could not detect credential type. " - "Please ensure your oauth or browser credentials are set up correctly.") - - else: # no authentication - headers = initialize_headers() - - return headers diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index 2e436c81..8ecaff0c 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -1,17 +1,15 @@ import json import time import webbrowser -from typing import Dict, Optional -from dataclasses import dataclass +from typing import Dict, Optional, Any from os import environ as env - +import os import requests from requests.structures import CaseInsensitiveDict from ytmusicapi.constants import (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_CODE_URL, OAUTH_SCOPE, OAUTH_TOKEN_URL, OAUTH_USER_AGENT) -from ytmusicapi.helpers import initialize_headers def is_oauth(headers: CaseInsensitiveDict) -> bool: @@ -25,101 +23,196 @@ def is_oauth(headers: CaseInsensitiveDict) -> bool: return all(key in headers for key in oauth_structure) -def is_custom_oauth(headers: CaseInsensitiveDict) -> bool: - """Checks whether the headers contain a Bearer token, indicating a custom OAuth implementation.""" - return "authorization" in headers and headers["authorization"].startswith("Bearer ") +class OAuthToken: + @classmethod + def from_response(cls, response): + return cls(**response.json()) -@dataclass -class OAuthCredentials: - client_id: Optional[str] = env.get('YTMA_OAUTH_ID', OAUTH_CLIENT_ID) - client_secret: Optional[str] = env.get('YTMA_OAUTH_SECRET', OAUTH_CLIENT_SECRET) + def __init__(self, access_token: str, refresh_token: str, scope: str, expires_at: int, + token_type: str, **etc: Optional[Any]): + self._access_token = access_token + self._refresh_token = refresh_token + self._scope = scope + self._expires_at: int = expires_at + self._token_type = token_type + @property + def access_token(self): + return self._access_token -class YTMusicOAuth: - """OAuth implementation for YouTube Music based on YouTube TV""" + @property + def refresh_token(self): + return self._refresh_token - def __init__(self, - session: requests.Session, - proxies: Dict = None, + @property + def token_type(self): + return self._token_type + + @property + def scope(self): + return self._scope + + @property + def expires_at(self): + return self._expires_at + + @property + def expires_in(self): + return self.expires_at - time.time() + + @property + def is_expiring(self) -> bool: + return self.expires_in < 60 + + def as_dict(self): + return { + 'access_token': self.access_token, + 'refresh_token': self.refresh_token, + 'scope': self.scope, + 'expires_at': self.expires_at, + 'expires_in': self.expires_in, + 'token_type': self.token_type + } + + def as_auth(self): + return f'{self.token_type} {self.access_token}' + + +class OAuthCredentials: + + @classmethod + def from_env(cls, + session: Optional[requests.Session] = None, + proxies: Optional[Dict] = None, *, - oauth_credentials: Optional[OAuthCredentials] = None): - """ - :param session: Session instance used for authorization requests. - :param proxies: Optional. Proxy configuration to be used by the session. - :param oauth_credentials: Optional. Instance of OAuthCredentials containing - a client id and secret used for authentication flow. Providing will override - those found in constants or provided as environmental variables. - """ - - self.credentials = oauth_credentials if oauth_credentials else OAuthCredentials() - - self._session = session + client_id_key='YTMA_OAUTH_CLIENT_ID', + client_secret_key='YTMA_OAUTH_CLIENT_SECRET'): + missing_keys = [x for x in [client_id_key, client_secret_key] if x not in env] + if missing_keys: + raise KeyError('Failed to build credentials from environment. Missing required keys: ', + missing_keys) + + return cls(client_id=env[client_id_key], + client_secret=env[client_secret_key], + session=session, + proxies=proxies) + + def __init__(self, + client_id: Optional[str] = OAUTH_CLIENT_ID, + client_secret: Optional[str] = OAUTH_CLIENT_SECRET, + session: Optional[requests.Session] = None, + proxies: Optional[Dict] = None): + self.client_id = client_id + self.client_secret = client_secret + self._session = session if session else requests.Session() if proxies: self._session.proxies.update(proxies) - def _send_request(self, url, data) -> requests.Response: - data.update({"client_id": self.credentials.client_id}) - headers = {"User-Agent": OAUTH_USER_AGENT} - return self._session.post(url, data, headers=headers) - def get_code(self) -> Dict: code_response = self._send_request(OAUTH_CODE_URL, data={"scope": OAUTH_SCOPE}) - response_json = code_response.json() - return response_json + return code_response.json() - @staticmethod - def _parse_token(response) -> Dict: - token = response.json() - token["expires_at"] = int(time.time()) + int(token["expires_in"]) - return token + def _send_request(self, url, data): + data.update({"client_id": self.client_id}) + return self._session.post(url, data, headers={"User-Agent": OAUTH_USER_AGENT}) - def get_token_from_code(self, device_code: str) -> Dict: + def token_from_code(self, device_code: str) -> OAuthToken: response = self._send_request( OAUTH_TOKEN_URL, data={ - "client_secret": self.credentials.client_secret, + "client_secret": self.client_secret, "grant_type": "http://oauth.net/grant_type/device/1.0", "code": device_code, }, ) - return self._parse_token(response) + return OAuthToken.from_response(response) - def refresh_token(self, refresh_token: str) -> Dict: + def prompt_for_token(self, open_browser: bool = False, to_file: Optional[str] = None): + code = self.get_code() + url = f"{code['verification_url']}?user_code={code['user_code']}" + if open_browser: + webbrowser.open(url) + input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") + raw_token = self.token_from_code(code["device_code"]) + ref_token = RefreshingToken(raw_token, credentials=self) + if to_file: + ref_token.local_cache = to_file + return ref_token + + def refresh_token(self, refresh_token: str) -> OAuthToken: response = self._send_request( OAUTH_TOKEN_URL, data={ - "client_secret": self.credentials.client_secret, + "client_secret": self.client_secret, "grant_type": "refresh_token", "refresh_token": refresh_token, }, ) - return self._parse_token(response) + return OAuthToken.from_response(response) - @staticmethod - def dump_token(token: Dict, filepath: Optional[str]): - if not filepath or len(filepath) > 255: - return - with open(filepath, encoding="utf8", mode="w") as file: - json.dump(token, file, indent=True) - def setup(self, filepath: Optional[str] = None, open_browser: bool = False) -> Dict: - code = self.get_code() - url = f"{code['verification_url']}?user_code={code['user_code']}" - if open_browser: - webbrowser.open(url) - input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") - token = self.get_token_from_code(code["device_code"]) - self.dump_token(token, filepath) - - return token - - def load_headers(self, token: Dict, filepath: Optional[str] = None): - headers = initialize_headers() - if time.time() > token["expires_at"] - 3600: - token.update(self.refresh_token(token["refresh_token"])) - self.dump_token(token, filepath) - headers["Authorization"] = f"{token['token_type']} {token['access_token']}" - headers["Content-Type"] = "application/json" - headers["X-Goog-Request-Time"] = str(int(time.time())) - return headers +class RefreshingToken(OAuthToken): + + @classmethod + def from_file(cls, file_path: str, credentials: Optional[OAuthCredentials] = None, sync=True): + if os.path.isfile(file_path): + with open(file_path) as json_file: + file_pack = json.load(json_file) + + creds = credentials if credentials else OAuthCredentials() + + return cls(OAuthToken(**file_pack), creds, file_path if sync else None) + + def __init__(self, + token: OAuthToken, + credentials: OAuthCredentials, + local_cache: Optional[str] = None): + self.token = token + self.credentials = credentials + self._local_cache = local_cache + + @property + def local_cache(self): + return self._local_cache + + # as a property so swapping it will automatically dump the token to the new location + @local_cache.setter + def local_cache(self, path: str): + with open(path, encoding="utf8", mode='w') as file: + json.dump(self.token.as_dict(), file, indent=True) + self._local_cache = path + + @property + def access_token(self): + if self.token.is_expiring: + self.token = self.credentials.refresh_token(self.token.refresh_token) + # update stored token file on refresh when provided + if self.local_cache: + with open(self.local_cache, encoding="utf8", mode='w') as file: + json.dump(self.token.as_dict(), file, indent=True) + return self.token.access_token + + @property + def refresh_token(self): + return self.token.refresh_token + + @property + def is_expiring(self) -> bool: + return False + + @property + def expires_at(self): + return None + + @property + def expires_in(self): + return None + + @property + def scope(self): + return self.token.scope + + @property + def token_type(self): + return self.token.token_type diff --git a/ytmusicapi/setup.py b/ytmusicapi/setup.py index 7a0edb5f..20f051ff 100644 --- a/ytmusicapi/setup.py +++ b/ytmusicapi/setup.py @@ -6,7 +6,7 @@ import requests from ytmusicapi.auth.browser import setup_browser -from ytmusicapi.auth.oauth import YTMusicOAuth +from ytmusicapi.auth.oauth import OAuthCredentials def setup(filepath: str = None, headers_raw: str = None) -> Dict: @@ -25,7 +25,10 @@ def setup(filepath: str = None, headers_raw: str = None) -> Dict: def setup_oauth(filepath: str = None, session: requests.Session = None, proxies: dict = None, - open_browser: bool = False) -> Dict: + open_browser: bool = False, + env_credentials: bool = False, + client_id: str = None, + client_secret: str = None) -> Dict: """ Starts oauth flow from the terminal and returns a string that can be passed to YTMusic() @@ -34,12 +37,33 @@ def setup_oauth(filepath: str = None, :param proxies: Proxies to use for authentication :param filepath: Optional filepath to store headers to. :param open_browser: If True, open the default browser with the setup link + :param env_credentials: Optional. Specifies if client_id and client_secret should be + pulled from the environment. Modifies client_id and client_secret parameters to + instead specify the keys to search environment for. Default keys are + YTMA_OAUTH_CLIENT_ID for client_id and YTMA_OAUTH_CLIENT_SECRET for secret. + :param client_id: Optional. Used to specify the client_id oauth should use for authentication + flow. If used with env_credentials, instead specifies the environmental key of client_id. + If not used with env_credentials, client_secret MUST also be passed or both will be ignored. + :param client_secret: Optional. Same as client_id but for the oauth client secret. + :return: configuration headers string """ if not session: session = requests.Session() - return YTMusicOAuth(session, proxies).setup(filepath, open_browser) + if env_credentials: + oauth_credentials = OAuthCredentials.from_env(session, + proxies, + client_id_key=client_id, + client_secret_key=client_secret) + + elif client_id and client_secret: + oauth_credentials = OAuthCredentials(client_id, client_secret, session, proxies) + + else: + oauth_credentials = OAuthCredentials(session=session, proxies=proxies) + + return oauth_credentials.prompt_for_token(open_browser, filepath) def parse_args(args): @@ -49,6 +73,7 @@ def parse_args(args): choices=["oauth", "browser"], help="choose a setup type.") parser.add_argument("--file", type=Path, help="optional path to output file.") + return parser.parse_args(args) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index a3dbc05e..cb54fb17 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -4,9 +4,10 @@ from functools import partial from contextlib import suppress from typing import Dict, Optional +import time from requests.structures import CaseInsensitiveDict -from ytmusicapi.auth.headers import load_headers_file, prepare_headers +from ytmusicapi.auth.headers import load_headers_file from ytmusicapi.parsers.i18n import Parser from ytmusicapi.helpers import * from ytmusicapi.mixins.browsing import BrowsingMixin @@ -16,7 +17,7 @@ from ytmusicapi.mixins.library import LibraryMixin from ytmusicapi.mixins.playlists import PlaylistsMixin from ytmusicapi.mixins.uploads import UploadsMixin -from ytmusicapi.auth.oauth import OAuthCredentials, YTMusicOAuth, is_oauth +from ytmusicapi.auth.oauth import OAuthCredentials, is_oauth, RefreshingToken, OAuthToken class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, @@ -69,16 +70,19 @@ def __init__(self, :param location: Optional. Can be used to change the location of the user. No location will be set by default. This means it is determined by the server. Available languages can be checked in the FAQ. - :param oauth_credentials: Optional. Used to specify a different oauth client id and secret to be + :param oauth_credentials: Optional. Used to specify a different oauth client to be used for authentication flow. """ + self._base_headers = None + self._headers = None self.auth = auth - self.input_dict = None + self._input_dict = {} + self.is_alt_oauth = False self.is_oauth_auth = False - if oauth_credentials is not None: - self.oauth_credentials = oauth_credentials - else: - self.oauth_credentials = OAuthCredentials() + self.is_browser_auth = False + self.is_custom_oauth = False + self._token = None + self.proxies = proxies if isinstance(requests_session, requests.Session): self._session = requests_session @@ -89,28 +93,28 @@ def __init__(self, else: # Use the Requests API module as a "session". self._session = requests.api - self.proxies = proxies + if oauth_credentials is not None: + self.oauth_credentials = oauth_credentials + else: + self.oauth_credentials = OAuthCredentials() + # see google cookie docs: https://policies.google.com/technologies/cookies # value from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L502 self.cookies = {'SOCS': 'CAI'} if self.auth is not None: if isinstance(self.auth, str): input_json = load_headers_file(self.auth) - self.input_dict = CaseInsensitiveDict(input_json) + self._input_dict = CaseInsensitiveDict(input_json) else: - self.input_dict = self.auth + self._input_dict = self.auth - self.is_oauth_auth = is_oauth(self.input_dict) - self.is_alt_oauth = self.is_oauth_auth and oauth_credentials is not None - - self.headers = prepare_headers(self._session, - proxies, - self.input_dict, - oauth_credentials=self.oauth_credentials) - - if 'x-goog-visitor-id' not in self.headers: - self.headers.update(get_visitor_id(self._send_get_request)) + if is_oauth(self._input_dict): + self.is_oauth_auth = True + self.is_alt_oauth = oauth_credentials is not None + self._token = RefreshingToken(OAuthToken(**self._input_dict), + self.oauth_credentials, + self._input_dict.get('filepath')) # prepare context self.context = initialize_context() @@ -138,31 +142,65 @@ def __init__(self, if user: self.context['context']['user']['onBehalfOfUser'] = user - auth_header = self.headers.get("authorization") - self.is_browser_auth = auth_header and "SAPISIDHASH" in auth_header + auth_headers = self._input_dict.get("authorization") + if auth_headers: + if "SAPISIDHASH" in auth_headers: + self.is_browser_auth = True + elif auth_headers.startswith('Bearer'): + self.is_custom_oauth = True + + # sapsid, origin, and params all set once during init + self.params = YTM_PARAMS if self.is_browser_auth: + self.params += YTM_PARAMS_KEY try: - cookie = self.headers.get('cookie') + cookie = self.base_headers.get('cookie') self.sapisid = sapisid_from_cookie(cookie) + self.origin = self.base_headers.get('origin', self.base_headers.get('x-origin')) except KeyError: raise Exception("Your cookie is missing the required value __Secure-3PAPISID") - def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") -> Dict: + @property + def base_headers(self): + if not self._base_headers: + if self.is_browser_auth or self.is_custom_oauth: + self._base_headers = self._input_dict + else: + self._base_headers = { + "user-agent": USER_AGENT, + "accept": "*/*", + "accept-encoding": "gzip, deflate", + "content-type": "application/json", + "content-encoding": "gzip", + "origin": YTM_DOMAIN + } + + return self._base_headers + + @property + def headers(self): + # set on first use + if not self._headers: + self._headers = self.base_headers + + # keys updated each use, custom oauth implementations left untouched + if self.is_browser_auth: + self._headers["authorization"] = get_authorization(self.sapisid + ' ' + self.origin) + + elif self.is_oauth_auth: + self._headers['authorization'] = self._token.as_auth() + self._headers['X-Goog-Request-Time'] = str(int(time.time())) - if self.is_oauth_auth: - self.headers = prepare_headers(self._session, - self.proxies, - self.input_dict, - oauth_credentials=self.oauth_credentials) + return self._headers + def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") -> Dict: body.update(self.context) - params = YTM_PARAMS - if self.is_browser_auth: - origin = self.headers.get('origin', self.headers.get('x-origin')) - self.headers["authorization"] = get_authorization(self.sapisid + ' ' + origin) - params += YTM_PARAMS_KEY - response = self._session.post(YTM_BASE_API + endpoint + params + additionalParams, + # only required for post requests (?) + if 'X-Goog-Visitor-Id' not in self.headers: + self._headers.update(get_visitor_id(self._send_get_request)) + + response = self._session.post(YTM_BASE_API + endpoint + self.params + additionalParams, json=body, headers=self.headers, proxies=self.proxies, @@ -176,11 +214,13 @@ def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") - return response_text def _send_get_request(self, url: str, params: Dict = None): - response = self._session.get(url, - params=params, - headers=self.headers, - proxies=self.proxies, - cookies=self.cookies) + response = self._session.get( + url, + params=params, + # handle first-use x-goog-visitor-id fetching + headers=self.headers if self._headers else self.base_headers, + proxies=self.proxies, + cookies=self.cookies) return response def _check_auth(self): From 8dac2e528f042203cbf63ecc94a51236808d6c4e Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Tue, 19 Dec 2023 13:49:03 -0600 Subject: [PATCH 203/238] fix: new token init --- ytmusicapi/auth/oauth.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index 8ecaff0c..8e68e3f4 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -27,14 +27,21 @@ class OAuthToken: @classmethod def from_response(cls, response): - return cls(**response.json()) + data = response.json() + return cls(**data) - def __init__(self, access_token: str, refresh_token: str, scope: str, expires_at: int, - token_type: str, **etc: Optional[Any]): + def __init__(self, + access_token: str, + refresh_token: str, + scope: str, + token_type: str, + expires_at: Optional[int] = None, + expires_in: Optional[int] = None, + **etc: Optional[Any]): self._access_token = access_token self._refresh_token = refresh_token self._scope = scope - self._expires_at: int = expires_at + self._expires_at: int = expires_at if expires_at else int(time.time() + expires_in) self._token_type = token_type @property From 5f1945ae056c765a4576ce9d9754745d117e7803 Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Tue, 19 Dec 2023 13:59:35 -0600 Subject: [PATCH 204/238] remove: env support --- ytmusicapi/auth/oauth.py | 18 ------------------ ytmusicapi/setup.py | 16 ++-------------- 2 files changed, 2 insertions(+), 32 deletions(-) diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py index 8e68e3f4..528a6f71 100644 --- a/ytmusicapi/auth/oauth.py +++ b/ytmusicapi/auth/oauth.py @@ -2,7 +2,6 @@ import time import webbrowser from typing import Dict, Optional, Any -from os import environ as env import os import requests @@ -88,23 +87,6 @@ def as_auth(self): class OAuthCredentials: - @classmethod - def from_env(cls, - session: Optional[requests.Session] = None, - proxies: Optional[Dict] = None, - *, - client_id_key='YTMA_OAUTH_CLIENT_ID', - client_secret_key='YTMA_OAUTH_CLIENT_SECRET'): - missing_keys = [x for x in [client_id_key, client_secret_key] if x not in env] - if missing_keys: - raise KeyError('Failed to build credentials from environment. Missing required keys: ', - missing_keys) - - return cls(client_id=env[client_id_key], - client_secret=env[client_secret_key], - session=session, - proxies=proxies) - def __init__(self, client_id: Optional[str] = OAUTH_CLIENT_ID, client_secret: Optional[str] = OAUTH_CLIENT_SECRET, diff --git a/ytmusicapi/setup.py b/ytmusicapi/setup.py index 20f051ff..f826ec41 100644 --- a/ytmusicapi/setup.py +++ b/ytmusicapi/setup.py @@ -26,7 +26,6 @@ def setup_oauth(filepath: str = None, session: requests.Session = None, proxies: dict = None, open_browser: bool = False, - env_credentials: bool = False, client_id: str = None, client_secret: str = None) -> Dict: """ @@ -37,13 +36,8 @@ def setup_oauth(filepath: str = None, :param proxies: Proxies to use for authentication :param filepath: Optional filepath to store headers to. :param open_browser: If True, open the default browser with the setup link - :param env_credentials: Optional. Specifies if client_id and client_secret should be - pulled from the environment. Modifies client_id and client_secret parameters to - instead specify the keys to search environment for. Default keys are - YTMA_OAUTH_CLIENT_ID for client_id and YTMA_OAUTH_CLIENT_SECRET for secret. :param client_id: Optional. Used to specify the client_id oauth should use for authentication - flow. If used with env_credentials, instead specifies the environmental key of client_id. - If not used with env_credentials, client_secret MUST also be passed or both will be ignored. + flow. If provided, client_secret MUST also be passed or both will be ignored. :param client_secret: Optional. Same as client_id but for the oauth client secret. :return: configuration headers string @@ -51,13 +45,7 @@ def setup_oauth(filepath: str = None, if not session: session = requests.Session() - if env_credentials: - oauth_credentials = OAuthCredentials.from_env(session, - proxies, - client_id_key=client_id, - client_secret_key=client_secret) - - elif client_id and client_secret: + if client_id and client_secret: oauth_credentials = OAuthCredentials(client_id, client_secret, session, proxies) else: From 814ea9999be2b76827b5311463f76f0d96183f46 Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Wed, 20 Dec 2023 15:15:25 -0600 Subject: [PATCH 205/238] get_album_browse_id: reverted and subtest added for edge case --- tests/test.py | 6 ++++++ ytmusicapi/mixins/browsing.py | 9 +++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test.py b/tests/test.py index 7d7199c9..fab17464 100644 --- a/tests/test.py +++ b/tests/test.py @@ -237,6 +237,11 @@ def test_get_user_playlists(self): def test_get_album_browse_id(self): browse_id = self.yt.get_album_browse_id("OLAK5uy_nMr9h2VlS-2PULNz3M3XVXQj_P3C2bqaY") self.assertEqual(browse_id, sample_album) + with self.subTest(): + escaped_browse_id = self.yt.get_album_browse_id( + "OLAK5uy_nbMYyrfeg5ZgknoOsOGBL268hGxtcbnDM") + # general length, I believe 17 is standard, but unsure if edge cases exist + self.assertLess(len(escaped_browse_id), 24) def test_get_album(self): results = self.yt_auth.get_album(sample_album) @@ -505,6 +510,7 @@ def test_edit_playlist(self): self.assertEqual(response, "STATUS_SUCCEEDED", "Playlist edit failed") # end to end test adding playlist, adding item, deleting item, deleting playlist + # @unittest.skip('You are creating too many playlists. Please wait a bit...') def test_end2end(self): playlist_id = self.yt_brand.create_playlist( "test", diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index fe4dbf26..4c644e13 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -342,10 +342,11 @@ def get_album_browse_id(self, audioPlaylistId: str) -> str | None: params = {"list": audioPlaylistId} response = self._send_get_request(YTM_DOMAIN + "/playlist", params) - if (start := response.text.find('MPREb_')) == -1: - return None - - return response.text[start:(response.text.find('"', start) - 1)] + matches = re.search(r"\"MPRE.+?\"", response.text.encode("utf8").decode("unicode_escape")) + browse_id = None + if matches: + browse_id = matches.group().strip('"') + return browse_id def get_album(self, browseId: str) -> Dict: """ From 45df3afdfbdde756ea5cc23ada071f8221390179 Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Wed, 20 Dec 2023 15:18:29 -0600 Subject: [PATCH 206/238] oauth: file separation, classes restructured, fix refresh error, model classes for clarity --- ytmusicapi/auth/oauth.py | 207 --------------------------- ytmusicapi/auth/oauth/__init__.py | 5 + ytmusicapi/auth/oauth/base.py | 96 +++++++++++++ ytmusicapi/auth/oauth/credentials.py | 84 +++++++++++ ytmusicapi/auth/oauth/models.py | 29 ++++ ytmusicapi/auth/oauth/refreshing.py | 76 ++++++++++ 6 files changed, 290 insertions(+), 207 deletions(-) delete mode 100644 ytmusicapi/auth/oauth.py create mode 100644 ytmusicapi/auth/oauth/__init__.py create mode 100644 ytmusicapi/auth/oauth/base.py create mode 100644 ytmusicapi/auth/oauth/credentials.py create mode 100644 ytmusicapi/auth/oauth/models.py create mode 100644 ytmusicapi/auth/oauth/refreshing.py diff --git a/ytmusicapi/auth/oauth.py b/ytmusicapi/auth/oauth.py deleted file mode 100644 index 528a6f71..00000000 --- a/ytmusicapi/auth/oauth.py +++ /dev/null @@ -1,207 +0,0 @@ -import json -import time -import webbrowser -from typing import Dict, Optional, Any -import os - -import requests -from requests.structures import CaseInsensitiveDict - -from ytmusicapi.constants import (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_CODE_URL, - OAUTH_SCOPE, OAUTH_TOKEN_URL, OAUTH_USER_AGENT) - - -def is_oauth(headers: CaseInsensitiveDict) -> bool: - oauth_structure = { - "access_token", - "expires_at", - "expires_in", - "token_type", - "refresh_token", - } - return all(key in headers for key in oauth_structure) - - -class OAuthToken: - - @classmethod - def from_response(cls, response): - data = response.json() - return cls(**data) - - def __init__(self, - access_token: str, - refresh_token: str, - scope: str, - token_type: str, - expires_at: Optional[int] = None, - expires_in: Optional[int] = None, - **etc: Optional[Any]): - self._access_token = access_token - self._refresh_token = refresh_token - self._scope = scope - self._expires_at: int = expires_at if expires_at else int(time.time() + expires_in) - self._token_type = token_type - - @property - def access_token(self): - return self._access_token - - @property - def refresh_token(self): - return self._refresh_token - - @property - def token_type(self): - return self._token_type - - @property - def scope(self): - return self._scope - - @property - def expires_at(self): - return self._expires_at - - @property - def expires_in(self): - return self.expires_at - time.time() - - @property - def is_expiring(self) -> bool: - return self.expires_in < 60 - - def as_dict(self): - return { - 'access_token': self.access_token, - 'refresh_token': self.refresh_token, - 'scope': self.scope, - 'expires_at': self.expires_at, - 'expires_in': self.expires_in, - 'token_type': self.token_type - } - - def as_auth(self): - return f'{self.token_type} {self.access_token}' - - -class OAuthCredentials: - - def __init__(self, - client_id: Optional[str] = OAUTH_CLIENT_ID, - client_secret: Optional[str] = OAUTH_CLIENT_SECRET, - session: Optional[requests.Session] = None, - proxies: Optional[Dict] = None): - self.client_id = client_id - self.client_secret = client_secret - self._session = session if session else requests.Session() - if proxies: - self._session.proxies.update(proxies) - - def get_code(self) -> Dict: - code_response = self._send_request(OAUTH_CODE_URL, data={"scope": OAUTH_SCOPE}) - return code_response.json() - - def _send_request(self, url, data): - data.update({"client_id": self.client_id}) - return self._session.post(url, data, headers={"User-Agent": OAUTH_USER_AGENT}) - - def token_from_code(self, device_code: str) -> OAuthToken: - response = self._send_request( - OAUTH_TOKEN_URL, - data={ - "client_secret": self.client_secret, - "grant_type": "http://oauth.net/grant_type/device/1.0", - "code": device_code, - }, - ) - return OAuthToken.from_response(response) - - def prompt_for_token(self, open_browser: bool = False, to_file: Optional[str] = None): - code = self.get_code() - url = f"{code['verification_url']}?user_code={code['user_code']}" - if open_browser: - webbrowser.open(url) - input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") - raw_token = self.token_from_code(code["device_code"]) - ref_token = RefreshingToken(raw_token, credentials=self) - if to_file: - ref_token.local_cache = to_file - return ref_token - - def refresh_token(self, refresh_token: str) -> OAuthToken: - response = self._send_request( - OAUTH_TOKEN_URL, - data={ - "client_secret": self.client_secret, - "grant_type": "refresh_token", - "refresh_token": refresh_token, - }, - ) - return OAuthToken.from_response(response) - - -class RefreshingToken(OAuthToken): - - @classmethod - def from_file(cls, file_path: str, credentials: Optional[OAuthCredentials] = None, sync=True): - if os.path.isfile(file_path): - with open(file_path) as json_file: - file_pack = json.load(json_file) - - creds = credentials if credentials else OAuthCredentials() - - return cls(OAuthToken(**file_pack), creds, file_path if sync else None) - - def __init__(self, - token: OAuthToken, - credentials: OAuthCredentials, - local_cache: Optional[str] = None): - self.token = token - self.credentials = credentials - self._local_cache = local_cache - - @property - def local_cache(self): - return self._local_cache - - # as a property so swapping it will automatically dump the token to the new location - @local_cache.setter - def local_cache(self, path: str): - with open(path, encoding="utf8", mode='w') as file: - json.dump(self.token.as_dict(), file, indent=True) - self._local_cache = path - - @property - def access_token(self): - if self.token.is_expiring: - self.token = self.credentials.refresh_token(self.token.refresh_token) - # update stored token file on refresh when provided - if self.local_cache: - with open(self.local_cache, encoding="utf8", mode='w') as file: - json.dump(self.token.as_dict(), file, indent=True) - return self.token.access_token - - @property - def refresh_token(self): - return self.token.refresh_token - - @property - def is_expiring(self) -> bool: - return False - - @property - def expires_at(self): - return None - - @property - def expires_in(self): - return None - - @property - def scope(self): - return self.token.scope - - @property - def token_type(self): - return self.token.token_type diff --git a/ytmusicapi/auth/oauth/__init__.py b/ytmusicapi/auth/oauth/__init__.py new file mode 100644 index 00000000..f8813a2c --- /dev/null +++ b/ytmusicapi/auth/oauth/__init__.py @@ -0,0 +1,5 @@ +from .credentials import OAuthCredentials, is_oauth +from .refreshing import RefreshingToken +from .base import OAuthToken + +__all__ = ['OAuthCredentials', 'is_oauth', 'RefreshingToken', 'OAuthToken'] diff --git a/ytmusicapi/auth/oauth/base.py b/ytmusicapi/auth/oauth/base.py new file mode 100644 index 00000000..1882dcd7 --- /dev/null +++ b/ytmusicapi/auth/oauth/base.py @@ -0,0 +1,96 @@ +from typing import Optional, Any, Union, Dict +import time +import json + +from .models import BaseTokenDict, DefaultScope, Bearer, FullTokenDict + + +class Credentials: + """ Base class representation of YouTubeMusicAPI OAuth Credentials """ + client_id: str + client_secret: str + + def get_code(self) -> Dict: + raise NotImplementedError() + + def token_from_code(self, device_code: str) -> FullTokenDict: + raise NotImplementedError() + + def refresh_token(self, refresh_token: str) -> BaseTokenDict: + raise NotImplementedError() + + +class Token: + """ Base class representation of the YouTubeMusicAPI OAuth token """ + access_token: str + refresh_token: str + expires_in: str + expires_at: str + + scope: Union[str, DefaultScope] + token_type: Union[str, Bearer] + + def as_dict(self): + return { + 'access_token': self.access_token, + 'refresh_token': self.refresh_token, + 'scope': self.scope, + 'expires_at': self.expires_at, + 'expires_in': self.expires_in, + 'token_type': self.token_type + } + + def as_json(self): + return json.dumps(self.as_dict()) + + def as_auth(self): + return f'{self.token_type} {self.access_token}' + + +class OAuthToken(Token): + + def __init__(self, + access_token: str, + refresh_token: str, + scope: str, + token_type: str, + expires_at: Optional[int] = None, + expires_in: Optional[int] = None, + **etc: Optional[Any]): + self._access_token = access_token + self._refresh_token = refresh_token + self._scope = scope + self._expires_at: int = expires_at if expires_at else int(time.time() + expires_in) + self._token_type = token_type + + def update(self, fresh_access: BaseTokenDict): + self._access_token = fresh_access['access_token'] + self._expires_at = int(time.time() + fresh_access['expires_in']) + + @property + def access_token(self): + return self._access_token + + @property + def refresh_token(self): + return self._refresh_token + + @property + def token_type(self): + return self._token_type + + @property + def scope(self): + return self._scope + + @property + def expires_at(self): + return self._expires_at + + @property + def expires_in(self): + return self.expires_at - time.time() + + @property + def is_expiring(self) -> bool: + return self.expires_in < 60 diff --git a/ytmusicapi/auth/oauth/credentials.py b/ytmusicapi/auth/oauth/credentials.py new file mode 100644 index 00000000..22535b0e --- /dev/null +++ b/ytmusicapi/auth/oauth/credentials.py @@ -0,0 +1,84 @@ +import webbrowser +from typing import Dict, Optional + +import requests +from requests.structures import CaseInsensitiveDict + +from .models import FullTokenDict, BaseTokenDict, CodeDict +from .base import OAuthToken, Credentials +from .refreshing import RefreshingToken + +from ytmusicapi.constants import (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_CODE_URL, + OAUTH_SCOPE, OAUTH_TOKEN_URL, OAUTH_USER_AGENT) + + +def is_oauth(headers: CaseInsensitiveDict) -> bool: + oauth_structure = { + "access_token", + "expires_at", + "expires_in", + "token_type", + "refresh_token", + } + return all(key in headers for key in oauth_structure) + + +class OAuthCredentials(Credentials): + + def __init__(self, + client_id: Optional[str] = OAUTH_CLIENT_ID, + client_secret: Optional[str] = OAUTH_CLIENT_SECRET, + session: Optional[requests.Session] = None, + proxies: Optional[Dict] = None): + self.client_id = client_id + self.client_secret = client_secret + self._session = session if session else requests.Session() + if proxies: + self._session.proxies.update(proxies) + + def get_code(self) -> CodeDict: + code_response = self._send_request(OAUTH_CODE_URL, data={"scope": OAUTH_SCOPE}) + return code_response.json() + + def _send_request(self, url, data): + data.update({"client_id": self.client_id}) + return self._session.post(url, data, headers={"User-Agent": OAUTH_USER_AGENT}) + + def token_from_code(self, device_code: str) -> FullTokenDict: + """ Returns a FullTokenDict, including refresh_token """ + response = self._send_request( + OAUTH_TOKEN_URL, + data={ + "client_secret": self.client_secret, + "grant_type": "http://oauth.net/grant_type/device/1.0", + "code": device_code, + }, + ) + return response.json() + + def prompt_for_token(self, + open_browser: bool = False, + to_file: Optional[str] = None) -> RefreshingToken: + code = self.get_code() + url = f"{code['verification_url']}?user_code={code['user_code']}" + if open_browser: + webbrowser.open(url) + input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") + raw_token = self.token_from_code(code["device_code"]) + base_token = OAuthToken(**raw_token) + ref_token = RefreshingToken(base_token, credentials=self) + if to_file: + ref_token.local_cache = to_file + return ref_token + + def refresh_token(self, refresh_token: str) -> BaseTokenDict: + response = self._send_request( + OAUTH_TOKEN_URL, + data={ + "client_secret": self.client_secret, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + }, + ) + + return response.json() diff --git a/ytmusicapi/auth/oauth/models.py b/ytmusicapi/auth/oauth/models.py new file mode 100644 index 00000000..d6245661 --- /dev/null +++ b/ytmusicapi/auth/oauth/models.py @@ -0,0 +1,29 @@ +# stand-in classes for clarity until pydantic implementation + +from typing import Union, Literal, TypedDict + +DefaultScope = Literal['https://www.googleapis.com/auth/youtube'] +Bearer = Literal['Bearer'] + + +class BaseTokenDict(TypedDict): + """ Limited token. Does not provide a refresh token. Commonly obtained via a token refresh. """ + access_token: str + expires_in: int + scope: Union[str, DefaultScope] + token_type: Union[str, Bearer] + + +class FullTokenDict(BaseTokenDict): + """ Entire token. Including refresh. Obtained through token setup. """ + expires_at: int + refresh_token: str + + +class CodeDict(TypedDict): + """ Keys for the json object obtained via code response during auth flow. """ + device_code: str + user_code: str + expires_in: int + interval: int + verification_url: str diff --git a/ytmusicapi/auth/oauth/refreshing.py b/ytmusicapi/auth/oauth/refreshing.py new file mode 100644 index 00000000..0c02d994 --- /dev/null +++ b/ytmusicapi/auth/oauth/refreshing.py @@ -0,0 +1,76 @@ +from typing import Optional, Any +import time +import os +import json + +from .base import OAuthToken, Token, Credentials + + +class RefreshingToken(Token): + + @classmethod + def from_file(cls, file_path: str, credentials: Credentials, sync=True): + if os.path.isfile(file_path): + with open(file_path) as json_file: + file_pack = json.load(json_file) + + return cls(OAuthToken(**file_pack), credentials, file_path if sync else None) + + def __init__(self, + token: OAuthToken, + credentials: Credentials, + local_cache: Optional[str] = None): + self.token = token + self.credentials = credentials + self._local_cache = local_cache + + @property + def local_cache(self): + return self._local_cache + + # as a property so swapping it will automatically dump the token to the new location + @local_cache.setter + def local_cache(self, path: str): + self._local_cache = path + self.store_token() + + @property + def access_token(self): + if self.token.is_expiring: + fresh = self.credentials.refresh_token(self.token.refresh_token) + self.token.update(fresh) + self.store_token() + + return self.token.access_token + + def store_token(self): + if self.local_cache: + with open(self.local_cache, encoding="utf8", mode='w') as file: + json.dump(self.token.as_dict(), file, indent=True) + + @property + def refresh_token(self): + return self.token.refresh_token + + @property + def is_expiring(self) -> bool: + return False + + @property + def expires_at(self): + return None + + @property + def expires_in(self): + return None + + @property + def scope(self): + return self.token.scope + + @property + def token_type(self): + return self.token.token_type + + def as_dict(self): + return self.token.as_dict() From 11bf25f0aa4b54e24f7376374fc2fda1c508c944 Mon Sep 17 00:00:00 2001 From: theyak Date: Tue, 26 Dec 2023 15:20:39 -0800 Subject: [PATCH 207/238] Move pop_songs_random_mix to be after results check. --- ytmusicapi/mixins/uploads.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index c4bbcf4a..91655bfa 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -46,9 +46,9 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D body["params"] = prepare_order_params(order) response = self._send_request(endpoint, body) results = get_library_contents(response, MUSIC_SHELF) - pop_songs_random_mix(results) if results is None: return [] + pop_songs_random_mix(results) songs = parse_uploaded_items(results['contents']) if 'continuations' in results: From b51603af90700fe852ae05773b6a903461a52cec Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 27 Dec 2023 22:15:41 +0100 Subject: [PATCH 208/238] update get_artist docstring (closes #489) --- ytmusicapi/mixins/browsing.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 61cf9ab9..d929971e 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -120,10 +120,10 @@ def get_artist(self, channelId: str) -> Dict: """ Get information about an artist and their top releases (songs, albums, singles, videos, and related artists). The top lists - contain pointers for getting the full list of releases. For - songs/videos, pass the browseId to :py:func:`get_playlist`. - For albums/singles, pass browseId and params to :py:func: - `get_artist_albums`. + contain pointers for getting the full list of releases. + + For songs/videos, pass the browseId to :py:func:`get_playlist`. + For albums/singles, pass browseId and params to :py:func:`get_artist_albums`. :param channelId: channel id of the artist :return: Dictionary with requested information. @@ -243,7 +243,7 @@ def get_artist_albums(self, channelId: str, params: str) -> List[Dict]: """ Get the full list of an artist's albums or singles - :param channelId: channel Id of the artist + :param channelId: browseId of the artist as returned by :py:func:`get_artist` :param params: params obtained by :py:func:`get_artist` :return: List of albums in the format of :py:func:`get_library_albums`, except artists key is missing. From e85ce83d3644c62d197c16f55bbc790411fdaac5 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 27 Dec 2023 22:35:29 +0100 Subject: [PATCH 209/238] update get_artist docstring once more (closes #480) --- ytmusicapi/mixins/browsing.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index d929971e..f64b3304 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -128,6 +128,11 @@ def get_artist(self, channelId: str) -> Dict: :param channelId: channel id of the artist :return: Dictionary with requested information. + .. warning:: + + The returned channelId is not the same as the one passed to the function. + It should be used only with :py:func:`subscribe_artists`. + Example:: { From 7b60e65ea4f82c170544f7ef4584cc160a56e5fe Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Thu, 28 Dec 2023 17:58:39 -0600 Subject: [PATCH 210/238] test_get_album_browse_id: hindsight test edit and bulletproof fix for warning --- tests/test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test.py b/tests/test.py index fab17464..85c91867 100644 --- a/tests/test.py +++ b/tests/test.py @@ -235,13 +235,13 @@ def test_get_user_playlists(self): self.assertGreater(len(results), 100) def test_get_album_browse_id(self): + warnings.filterwarnings(action="ignore", category=DeprecationWarning) browse_id = self.yt.get_album_browse_id("OLAK5uy_nMr9h2VlS-2PULNz3M3XVXQj_P3C2bqaY") self.assertEqual(browse_id, sample_album) with self.subTest(): escaped_browse_id = self.yt.get_album_browse_id( "OLAK5uy_nbMYyrfeg5ZgknoOsOGBL268hGxtcbnDM") - # general length, I believe 17 is standard, but unsure if edge cases exist - self.assertLess(len(escaped_browse_id), 24) + self.assertEqual(escaped_browse_id, 'MPREb_scJdtUCpPE2') def test_get_album(self): results = self.yt_auth.get_album(sample_album) From 38685c9b1718742843d06867a0d861a9589e231c Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Thu, 28 Dec 2023 18:00:12 -0600 Subject: [PATCH 211/238] explore.py: charts fix and docs edit --- ytmusicapi/mixins/explore.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ytmusicapi/mixins/explore.py b/ytmusicapi/mixins/explore.py index 9b554ef3..5429835b 100644 --- a/ytmusicapi/mixins/explore.py +++ b/ytmusicapi/mixins/explore.py @@ -95,8 +95,8 @@ def get_charts(self, country: str = 'ZZ') -> Dict: Global charts have no Trending section, US charts have an extra Genres section with some Genre charts. :param country: ISO 3166-1 Alpha-2 country code. Default: ZZ = Global - :return: Dictionary containing chart songs (only if authenticated and available), chart videos, chart artists and - trending videos. + :return: Dictionary containing chart songs (only if authenticated with premium account), + chart videos, chart artists and trending videos. Example:: @@ -192,8 +192,8 @@ def get_charts(self, country: str = 'ZZ') -> Dict: body = {'browseId': 'FEmusic_charts'} if country: body['formData'] = {'selectedValues': [country]} - endpoint = 'browse' - response = self._send_request(endpoint, body) + + response = self._send_request('browse', body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) charts = {'countries': {}} menu = nav( @@ -211,10 +211,10 @@ def get_charts(self, country: str = 'ZZ') -> Dict: has_genres = country == 'US' has_trending = country != 'ZZ' - # songs section appears to no longer exist, extra length check avoids - # index errors and will still include songs if the feature is added back - has_songs = bool( - self.auth) and len(results) - 1 > (len(charts_categories) + has_genres + has_trending) + + # use result length to determine if songs category is present + # could also be done via an is_premium attribute on YTMusic instance + has_songs = (len(results) - 1) > (len(charts_categories) + has_genres + has_trending) if has_songs: charts_categories.insert(0, 'songs') From fe85234ce3e8d5f914564c8f058fdca71909a8f3 Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Thu, 28 Dec 2023 18:04:54 -0600 Subject: [PATCH 212/238] credentials.py: custom exceptions.py for pesky issues --- ytmusicapi/auth/oauth/credentials.py | 20 ++++++++++++++++++-- ytmusicapi/auth/oauth/exceptions.py | 12 ++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 ytmusicapi/auth/oauth/exceptions.py diff --git a/ytmusicapi/auth/oauth/credentials.py b/ytmusicapi/auth/oauth/credentials.py index 22535b0e..514f628a 100644 --- a/ytmusicapi/auth/oauth/credentials.py +++ b/ytmusicapi/auth/oauth/credentials.py @@ -7,6 +7,7 @@ from .models import FullTokenDict, BaseTokenDict, CodeDict from .base import OAuthToken, Credentials from .refreshing import RefreshingToken +from .exceptions import BadOAuthClient, UnauthorizedOAuthClient from ytmusicapi.constants import (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_CODE_URL, OAUTH_SCOPE, OAUTH_TOKEN_URL, OAUTH_USER_AGENT) @@ -42,10 +43,25 @@ def get_code(self) -> CodeDict: def _send_request(self, url, data): data.update({"client_id": self.client_id}) - return self._session.post(url, data, headers={"User-Agent": OAUTH_USER_AGENT}) + response = self._session.post(url, data, headers={"User-Agent": OAUTH_USER_AGENT}) + if response.status_code == 401: + data = response.json() + issue = data.get('error') + if issue == 'unauthorized_client': + raise UnauthorizedOAuthClient( + 'Token refresh error. Most likely client/token mismatch.') + + elif issue == 'invalid_client': + raise BadOAuthClient( + 'OAuth client failure. Most likely client_id and client_secret mismatch or ' + 'YouTubeData API is not enabled.') + else: + raise Exception( + f'OAuth request error. status_code: {response.status_code}, url: {url}, content: {data}' + ) + return response def token_from_code(self, device_code: str) -> FullTokenDict: - """ Returns a FullTokenDict, including refresh_token """ response = self._send_request( OAUTH_TOKEN_URL, data={ diff --git a/ytmusicapi/auth/oauth/exceptions.py b/ytmusicapi/auth/oauth/exceptions.py new file mode 100644 index 00000000..04069aee --- /dev/null +++ b/ytmusicapi/auth/oauth/exceptions.py @@ -0,0 +1,12 @@ +class BadOAuthClient(Exception): + """ + OAuth client request failure. + Ensure provided client_id and secret are correct and YouTubeData API is enabled. + """ + + +class UnauthorizedOAuthClient(Exception): + """ + OAuth client lacks permissions for specified token. + Token can only be refreshed by OAuth credentials used for its creation. + """ From a589eadcab8868e4089cb4c29ad65503f2857ccb Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Thu, 28 Dec 2023 18:13:11 -0600 Subject: [PATCH 213/238] ytmusic: oauth initialization changes to fix keyerrors, reduce code and improve speed --- ytmusicapi/auth/oauth/__init__.py | 4 ++-- ytmusicapi/auth/oauth/credentials.py | 11 ----------- ytmusicapi/ytmusic.py | 19 +++++++++++++------ 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/ytmusicapi/auth/oauth/__init__.py b/ytmusicapi/auth/oauth/__init__.py index f8813a2c..d0b86c6a 100644 --- a/ytmusicapi/auth/oauth/__init__.py +++ b/ytmusicapi/auth/oauth/__init__.py @@ -1,5 +1,5 @@ -from .credentials import OAuthCredentials, is_oauth +from .credentials import OAuthCredentials from .refreshing import RefreshingToken from .base import OAuthToken -__all__ = ['OAuthCredentials', 'is_oauth', 'RefreshingToken', 'OAuthToken'] +__all__ = ['OAuthCredentials', 'RefreshingToken', 'OAuthToken'] diff --git a/ytmusicapi/auth/oauth/credentials.py b/ytmusicapi/auth/oauth/credentials.py index 514f628a..2d3e5b46 100644 --- a/ytmusicapi/auth/oauth/credentials.py +++ b/ytmusicapi/auth/oauth/credentials.py @@ -13,17 +13,6 @@ OAUTH_SCOPE, OAUTH_TOKEN_URL, OAUTH_USER_AGENT) -def is_oauth(headers: CaseInsensitiveDict) -> bool: - oauth_structure = { - "access_token", - "expires_at", - "expires_in", - "token_type", - "refresh_token", - } - return all(key in headers for key in oauth_structure) - - class OAuthCredentials(Credentials): def __init__(self, diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index cb54fb17..ff7b26e3 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -7,7 +7,6 @@ import time from requests.structures import CaseInsensitiveDict -from ytmusicapi.auth.headers import load_headers_file from ytmusicapi.parsers.i18n import Parser from ytmusicapi.helpers import * from ytmusicapi.mixins.browsing import BrowsingMixin @@ -17,7 +16,10 @@ from ytmusicapi.mixins.library import LibraryMixin from ytmusicapi.mixins.playlists import PlaylistsMixin from ytmusicapi.mixins.uploads import UploadsMixin -from ytmusicapi.auth.oauth import OAuthCredentials, is_oauth, RefreshingToken, OAuthToken + +from .auth.headers import load_headers_file +from .auth.oauth import OAuthCredentials, RefreshingToken, OAuthToken +from .auth.oauth.base import Token class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, @@ -109,12 +111,17 @@ def __init__(self, else: self._input_dict = self.auth - if is_oauth(self._input_dict): + # check if OAuth by passing values as kwargs to OAuthToken init using + # KeyError from incompatible input_dict as False + try: + base_token = OAuthToken(**self._input_dict) + self._token = RefreshingToken(base_token, self.oauth_credentials, + self._input_dict.get('filepath')) self.is_oauth_auth = True self.is_alt_oauth = oauth_credentials is not None - self._token = RefreshingToken(OAuthToken(**self._input_dict), - self.oauth_credentials, - self._input_dict.get('filepath')) + + except TypeError: + pass # prepare context self.context = initialize_context() From 940b827fb495d026c546ed46f431523a3224c0fc Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Thu, 28 Dec 2023 18:16:22 -0600 Subject: [PATCH 214/238] docs: docs, ~~docs~~, and docs with a splash of type hinting --- ytmusicapi/auth/oauth/base.py | 66 +++++++++++++++++-------- ytmusicapi/auth/oauth/credentials.py | 56 +++++++++++++++++----- ytmusicapi/auth/oauth/models.py | 34 +++++++------ ytmusicapi/auth/oauth/refreshing.py | 72 +++++++++++++++++++++------- ytmusicapi/ytmusic.py | 32 ++++++++----- 5 files changed, 187 insertions(+), 73 deletions(-) diff --git a/ytmusicapi/auth/oauth/base.py b/ytmusicapi/auth/oauth/base.py index 1882dcd7..cd59ce19 100644 --- a/ytmusicapi/auth/oauth/base.py +++ b/ytmusicapi/auth/oauth/base.py @@ -1,8 +1,8 @@ -from typing import Optional, Any, Union, Dict +from typing import Optional, Any, Dict import time import json -from .models import BaseTokenDict, DefaultScope, Bearer, FullTokenDict +from .models import BaseTokenDict, DefaultScope, Bearer, RefreshableTokenDict class Credentials: @@ -13,7 +13,7 @@ class Credentials: def get_code(self) -> Dict: raise NotImplementedError() - def token_from_code(self, device_code: str) -> FullTokenDict: + def token_from_code(self, device_code: str) -> RefreshableTokenDict: raise NotImplementedError() def refresh_token(self, refresh_token: str) -> BaseTokenDict: @@ -21,16 +21,22 @@ def refresh_token(self, refresh_token: str) -> BaseTokenDict: class Token: - """ Base class representation of the YouTubeMusicAPI OAuth token """ + """ Base class representation of the YouTubeMusicAPI OAuth token. """ access_token: str refresh_token: str - expires_in: str - expires_at: str + expires_in: int + expires_at: int + is_expiring: bool - scope: Union[str, DefaultScope] - token_type: Union[str, Bearer] + scope: DefaultScope + token_type: Bearer - def as_dict(self): + def __repr__(self) -> str: + """ Readable version. """ + return f'{self.__class__.__name__}: {self.as_dict()}' + + def as_dict(self) -> RefreshableTokenDict: + """ Returns dictionary containing underlying token values. """ return { 'access_token': self.access_token, 'refresh_token': self.refresh_token, @@ -40,14 +46,17 @@ def as_dict(self): 'token_type': self.token_type } - def as_json(self): + def as_json(self) -> str: + # TODO: [PYDANTIC]: add a custom serializer return json.dumps(self.as_dict()) - def as_auth(self): + def as_auth(self) -> str: + """ Returns Authorization header ready str of token_type and access_token. """ return f'{self.token_type} {self.access_token}' class OAuthToken(Token): + """ Wrapper for an OAuth token implementing expiration methods. """ def __init__(self, access_token: str, @@ -56,40 +65,59 @@ def __init__(self, token_type: str, expires_at: Optional[int] = None, expires_in: Optional[int] = None, - **etc: Optional[Any]): + **_: Optional[Any]): + """ + + :param access_token: active oauth key + :param refresh_token: access_token's matching oauth refresh string + :param scope: most likely 'https://www.googleapis.com/auth/youtube' + :param token_type: commonly 'Bearer' + :param expires_at: Optional. Unix epoch (seconds) of access token expiration. + :param expires_in: Optional. Seconds till expiration, assumes/calculates epoch of init. + :param _: void excess kwargs + + """ + # match baseclass attribute/property format self._access_token = access_token self._refresh_token = refresh_token self._scope = scope + + # set/calculate token expiration using current epoch self._expires_at: int = expires_at if expires_at else int(time.time() + expires_in) self._token_type = token_type def update(self, fresh_access: BaseTokenDict): + """ + Update access_token and expiration attributes with a BaseTokenDict inplace. + expires_at attribute set using current epoch, avoid expiration desync + by passing only recently requested tokens dicts or updating values to compensate. + """ self._access_token = fresh_access['access_token'] self._expires_at = int(time.time() + fresh_access['expires_in']) @property - def access_token(self): + def access_token(self) -> str: return self._access_token @property - def refresh_token(self): + def refresh_token(self) -> str: return self._refresh_token @property - def token_type(self): + def token_type(self) -> Bearer: return self._token_type @property - def scope(self): + def scope(self) -> DefaultScope: return self._scope @property - def expires_at(self): + def expires_at(self) -> int: return self._expires_at @property - def expires_in(self): - return self.expires_at - time.time() + def expires_in(self) -> int: + return int(self.expires_at - time.time()) @property def is_expiring(self) -> bool: diff --git a/ytmusicapi/auth/oauth/credentials.py b/ytmusicapi/auth/oauth/credentials.py index 2d3e5b46..2afef4cd 100644 --- a/ytmusicapi/auth/oauth/credentials.py +++ b/ytmusicapi/auth/oauth/credentials.py @@ -2,9 +2,8 @@ from typing import Dict, Optional import requests -from requests.structures import CaseInsensitiveDict -from .models import FullTokenDict, BaseTokenDict, CodeDict +from .models import RefreshableTokenDict, BaseTokenDict, AuthCodeDict from .base import OAuthToken, Credentials from .refreshing import RefreshingToken from .exceptions import BadOAuthClient, UnauthorizedOAuthClient @@ -14,23 +13,44 @@ class OAuthCredentials(Credentials): + """ + Class for handling OAuth credential retrieval and refreshing. + """ def __init__(self, - client_id: Optional[str] = OAUTH_CLIENT_ID, - client_secret: Optional[str] = OAUTH_CLIENT_SECRET, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, session: Optional[requests.Session] = None, proxies: Optional[Dict] = None): - self.client_id = client_id - self.client_secret = client_secret - self._session = session if session else requests.Session() + """ + :param client_id: Optional. Set the GoogleAPI client_id used for auth flows. + Requires client_secret also be provided if set. + :param client_secret: Optional. Corresponding secret for provided client_id. + :param session: Optional. Connection pooling with an active session. + :param proxies: Optional. Modify the session with proxy parameters. + """ + # id, secret should be None, None or str, str + if not isinstance(client_id, type(client_secret)): + raise KeyError( + 'OAuthCredential init failure. Provide both client_id and client_secret or neither.' + ) + + # bind instance to OAuth client for auth flows + self.client_id = client_id if client_id else OAUTH_CLIENT_ID + self.client_secret = client_secret if client_secret else OAUTH_CLIENT_SECRET + + self._session = session if session else requests.Session() # for auth requests if proxies: self._session.proxies.update(proxies) - def get_code(self) -> CodeDict: + def get_code(self) -> AuthCodeDict: + """ Method for obtaining a new user auth code. First step of token creation. """ code_response = self._send_request(OAUTH_CODE_URL, data={"scope": OAUTH_SCOPE}) return code_response.json() def _send_request(self, url, data): + """ Method for sending post requests with required client_id and User-Agent modifications """ + data.update({"client_id": self.client_id}) response = self._session.post(url, data, headers={"User-Agent": OAUTH_USER_AGENT}) if response.status_code == 401: @@ -50,7 +70,8 @@ def _send_request(self, url, data): ) return response - def token_from_code(self, device_code: str) -> FullTokenDict: + def token_from_code(self, device_code: str) -> RefreshableTokenDict: + """ Method for verifying user auth code and conversion into a FullTokenDict. """ response = self._send_request( OAUTH_TOKEN_URL, data={ @@ -64,19 +85,32 @@ def token_from_code(self, device_code: str) -> FullTokenDict: def prompt_for_token(self, open_browser: bool = False, to_file: Optional[str] = None) -> RefreshingToken: + """ + Method for CLI token creation via user inputs. + + :param open_browser: Optional. Open browser to OAuth consent url automatically. (Default = False). + :param to_file: Optional. Path to store/sync json version of resulting token. (Default = None). + """ + code = self.get_code() url = f"{code['verification_url']}?user_code={code['user_code']}" if open_browser: webbrowser.open(url) input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") raw_token = self.token_from_code(code["device_code"]) - base_token = OAuthToken(**raw_token) - ref_token = RefreshingToken(base_token, credentials=self) + ref_token = RefreshingToken(OAuthToken(**raw_token), credentials=self) if to_file: ref_token.local_cache = to_file return ref_token def refresh_token(self, refresh_token: str) -> BaseTokenDict: + """ + Method for requesting a new access token for a given refresh_token. + Token must have been created by the same OAuth client. + + :param refresh_token: Corresponding refresh_token for a matching access_token. + Obtained via + """ response = self._send_request( OAUTH_TOKEN_URL, data={ diff --git a/ytmusicapi/auth/oauth/models.py b/ytmusicapi/auth/oauth/models.py index d6245661..7778bbf2 100644 --- a/ytmusicapi/auth/oauth/models.py +++ b/ytmusicapi/auth/oauth/models.py @@ -2,28 +2,32 @@ from typing import Union, Literal, TypedDict -DefaultScope = Literal['https://www.googleapis.com/auth/youtube'] -Bearer = Literal['Bearer'] +# for static typechecking +DefaultScope = Union[str, Literal['https://www.googleapis.com/auth/youtube']] +Bearer = Union[str, Literal['Bearer']] class BaseTokenDict(TypedDict): """ Limited token. Does not provide a refresh token. Commonly obtained via a token refresh. """ - access_token: str - expires_in: int - scope: Union[str, DefaultScope] - token_type: Union[str, Bearer] + access_token: str # str to be used in Authorization header + expires_in: int # seconds until expiration from request timestamp + scope: DefaultScope # should be 'https://www.googleapis.com/auth/youtube' + token_type: Bearer # should be 'Bearer' -class FullTokenDict(BaseTokenDict): + +class RefreshableTokenDict(BaseTokenDict): """ Entire token. Including refresh. Obtained through token setup. """ - expires_at: int - refresh_token: str + + expires_at: int # UNIX epoch timestamp in seconds + refresh_token: str # str used to obtain new access token upon expiration -class CodeDict(TypedDict): +class AuthCodeDict(TypedDict): """ Keys for the json object obtained via code response during auth flow. """ - device_code: str - user_code: str - expires_in: int - interval: int - verification_url: str + + device_code: str # code obtained via user confirmation and oauth consent + user_code: str # alphanumeric code user is prompted to enter as confirmation. formatted as XXX-XXX-XXX. + expires_in: int # seconds from original request timestamp + interval: int # (?) "5" (?) + verification_url: str # base url for OAuth consent screen for user signin/confirmation diff --git a/ytmusicapi/auth/oauth/refreshing.py b/ytmusicapi/auth/oauth/refreshing.py index 0c02d994..82df8d1f 100644 --- a/ytmusicapi/auth/oauth/refreshing.py +++ b/ytmusicapi/auth/oauth/refreshing.py @@ -1,15 +1,31 @@ -from typing import Optional, Any -import time +from typing import Optional import os import json from .base import OAuthToken, Token, Credentials +from .models import RefreshableTokenDict, Bearer, DefaultScope class RefreshingToken(Token): + """ + Compositional implementation of Token that automatically refreshes + an underlying OAuthToken when required (credential expiration <= 1 min) + upon access_token attribute access. + """ @classmethod def from_file(cls, file_path: str, credentials: Credentials, sync=True): + """ + Initialize a refreshing token and underlying OAuthToken directly from a file. + + :param file_path: path to json containing token values + :param credentials: credentials used with token in file. + :param sync: Optional. Whether to pass the filepath into instance enabling file + contents to be updated upon refresh. (Default=True). + :return: RefreshingToken instance + :rtype: RefreshingToken + """ + if os.path.isfile(file_path): with open(file_path) as json_file: file_pack = json.load(json_file) @@ -20,22 +36,32 @@ def __init__(self, token: OAuthToken, credentials: Credentials, local_cache: Optional[str] = None): - self.token = token - self.credentials = credentials + """ + :param token: Underlying Token being maintained. + :param credentials: OAuth client being used for refreshing. + :param local_cache: Optional. Path to json file where token values are stored. + When provided, file contents is updated upon token refresh. + """ + + self.token: OAuthToken = token # internal token being used / refreshed / maintained + self.credentials = credentials # credentials used for access_token refreshing + + # protected/property attribute enables auto writing token + # values to new file location via setter self._local_cache = local_cache @property - def local_cache(self): + def local_cache(self) -> str | None: return self._local_cache - # as a property so swapping it will automatically dump the token to the new location @local_cache.setter def local_cache(self, path: str): + """ Update attribute and dump token to new path. """ self._local_cache = path self.store_token() @property - def access_token(self): + def access_token(self) -> str: if self.token.is_expiring: fresh = self.credentials.refresh_token(self.token.refresh_token) self.token.update(fresh) @@ -43,34 +69,48 @@ def access_token(self): return self.token.access_token - def store_token(self): - if self.local_cache: - with open(self.local_cache, encoding="utf8", mode='w') as file: + def store_token(self, path: Optional[str] = None) -> None: + """ + Write token values to json file at specified path, defaulting to self.local_cache. + Operation does not update instance local_cache attribute. + Automatically called when local_cache is set post init. + """ + file_path = path if path else self.local_cache + + if file_path: + with open(file_path, encoding="utf8", mode='w') as file: json.dump(self.token.as_dict(), file, indent=True) @property - def refresh_token(self): + def refresh_token(self) -> str: + # pass underlying value return self.token.refresh_token @property def is_expiring(self) -> bool: + # Refreshing token never expires return False @property - def expires_at(self): + def expires_at(self) -> None: + # Refreshing token never expires return None @property - def expires_in(self): + def expires_in(self) -> None: + # Refreshing token never expires return None @property - def scope(self): + def scope(self) -> DefaultScope: + # pass underlying value return self.token.scope @property - def token_type(self): + def token_type(self) -> Bearer: + # pass underlying value return self.token.token_type - def as_dict(self): + def as_dict(self) -> RefreshableTokenDict: + # override base class method with call to underlying token's method return self.token.as_dict() diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index ff7b26e3..6198054f 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -31,10 +31,10 @@ class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin """ def __init__(self, - auth: Optional[str | dict] = None, + auth: Optional[str | Dict] = None, user: str = None, requests_session=True, - proxies: dict = None, + proxies: Dict = None, language: str = 'en', location: str = '', oauth_credentials: Optional[OAuthCredentials] = None): @@ -75,16 +75,24 @@ def __init__(self, :param oauth_credentials: Optional. Used to specify a different oauth client to be used for authentication flow. """ - self._base_headers = None - self._headers = None - self.auth = auth - self._input_dict = {} - self.is_alt_oauth = False - self.is_oauth_auth = False - self.is_browser_auth = False - self.is_custom_oauth = False - self._token = None - self.proxies = proxies + + self._base_headers = None # for authless initializing requests during OAuth flow + self._headers = None # cache formed headers including auth + + self.auth = auth # raw auth + self._input_dict = {} # parsed auth arg value in dictionary format + + # (?) may be better implemented as an auth_type attribute with a literal/enum value (?) + self.is_alt_oauth = False # YTM instance is using a non-default OAuth client (id & secret) + self.is_oauth_auth = False # client auth via OAuth token refreshing + self.is_browser_auth = False # authorization via extracted browser headers, enables uploading capabilities + self.is_custom_oauth = False # allows fully formed OAuth headers to ignore browser auth refresh flow + + self._token: Token # OAuth credential handler + self.oauth_credentials: OAuthCredentials # Client used for OAuth refreshing + + self._session: requests.Session # request session for connection pooling + self.proxies: Dict = proxies # params for session modification if isinstance(requests_session, requests.Session): self._session = requests_session From a6ae5add7f052b59255dba1e50cadba8313897d3 Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Thu, 28 Dec 2023 18:23:51 -0600 Subject: [PATCH 215/238] tests: further edits for clarity and oauth tests --- tests/README.rst | 20 ++++++++-- tests/test.cfg.example | 6 +-- tests/test.py | 86 +++++++++++++++++++++++++++++++----------- 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/tests/README.rst b/tests/README.rst index de78fd1e..ff54d83f 100644 --- a/tests/README.rst +++ b/tests/README.rst @@ -1,15 +1,29 @@ Package tests ============================================ Tests use the ``unittest`` framework. Each function has a corresponding unittest. -Sometimes there is a single unittest for multiple functions to ensure there are no permanent changes in the user's YouTube account (i.e. subscribe and unsubscribe). +Sometimes there is a single unittest for multiple functions to ensure there are no permanent changes in the user's +YouTube account (i.e. subscribe and unsubscribe). + +Note that there must be a ``browser.json`` and ``oauth.json`` in the `tests` folder to run all authenticated tests. +These two files can be easily obtained as the default outputs of running the following commands respectively: + +.. code-block:: bash + + ytmusicapi browser + ytmusicapi oauth + -Note that there must be a ``headers_auth.json`` in the `tests` folder to run authenticated tests. -For testing the song upload, there also needs to be a file with the name specified in the code in the project root. Copy ``tests/test.cfg.example`` to ``tests/test.cfg`` to run the tests. The entry descriptions should be self-explanatory. For the headers_raw, you need to indent the overflowing lines with a tab character. For the upload test you need a suitable music file in the test directory. Adjust the file to contain appropriate information for your YouTube account and local setup. +Brand accounts can be created by first signing into the google account you wish to have as the parent/controlling +account then navigating `here. `_ +Once the brand account/channel has been created, you can obtain the account ID needed for your test.cfg by +navigating to your `google account page `_ and selecting the brand account via the +profile drop down in the top right, the brand ID should then be present in the URL. + Coverage badge -------------- Make sure you installed the dev requirements as explained in `CONTRIBUTING.rst `_. Run diff --git a/tests/test.cfg.example b/tests/test.cfg.example index e0a9c901..c593ea8b 100644 --- a/tests/test.cfg.example +++ b/tests/test.cfg.example @@ -3,10 +3,8 @@ brand_account = 101234229123420379537 brand_account_empty = 1123456629123420379537 headers = headers_auth_json_as_string headers_empty = headers_account_with_empty_library_as_json_as_string -headers_file = ./headers_auth.json -headers_oauth = ./oauth.json -oauth_code = {"device_code":"","user_code":"","expires_in":1800,"interval":5,"verification_url":"https://www.google.com/device"} -oauth_token = {"access_token":"","expires_in":1000,"refresh_token":"","scope":"https://www.googleapis.com/auth/youtube","token_type":"Bearer"} +browser_file = ./browser.json +oauth_file = ./oauth.json headers_raw = raw_headers_pasted_from_browser [queries] diff --git a/tests/test.py b/tests/test.py index 85c91867..99f6e79f 100644 --- a/tests/test.py +++ b/tests/test.py @@ -24,14 +24,18 @@ def get_resource(file: str) -> str: sample_album = "MPREb_4pL8gzRtw1p" # Eminem - Revival sample_video = "hpSrLjc5SMs" # Oasis - Wonderwall sample_playlist = "PL6bPxvf5dW5clc3y9wAoslzqUrmkZ5c-u" # very large playlist +blank_code = { + "device_code": "", + "user_code": "", + "expires_in": 1800, + "interval": 5, + "verification_url": "https://www.google.com/device" +} -headers_oauth = get_resource(config["auth"]["headers_oauth"]) -headers_browser = get_resource(config["auth"]["headers_file"]) +oauth_filepath = get_resource(config["auth"]["oauth_file"]) +browser_filepath = get_resource(config["auth"]["browser_file"]) -alt_oauth_creds = OAuthCredentials(**{ - 'client_id': OAUTH_CLIENT_ID, - 'client_secret': OAUTH_CLIENT_SECRET -}) +alt_oauth_creds = OAuthCredentials(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) class TestYTMusic(unittest.TestCase): @@ -42,16 +46,16 @@ def setUpClass(cls): with YTMusic(requests_session=False) as yt: assert isinstance(yt, YTMusic) cls.yt = YTMusic() - cls.yt_oauth = YTMusic(headers_oauth) - cls.yt_alt_oauth = YTMusic(headers_browser, oauth_credentials=alt_oauth_creds) - cls.yt_auth = YTMusic(headers_browser, location="GB") + cls.yt_oauth = YTMusic(oauth_filepath) + cls.yt_alt_oauth = YTMusic(browser_filepath, oauth_credentials=alt_oauth_creds) + cls.yt_auth = YTMusic(browser_filepath, location="GB") cls.yt_brand = YTMusic(config["auth"]["headers"], config["auth"]["brand_account"]) cls.yt_empty = YTMusic(config["auth"]["headers_empty"], config["auth"]["brand_account_empty"]) - @mock.patch("sys.argv", ["ytmusicapi", "browser", "--file", headers_browser]) + @mock.patch("sys.argv", ["ytmusicapi", "browser", "--file", browser_filepath]) def test_setup_browser(self): - headers = setup(headers_browser, config["auth"]["headers_raw"]) + headers = setup(browser_filepath, config["auth"]["headers_raw"]) self.assertGreaterEqual(len(headers), 2) headers_raw = config["auth"]["headers_raw"].split("\n") with mock.patch("builtins.input", side_effect=(headers_raw + [EOFError()])): @@ -60,28 +64,64 @@ def test_setup_browser(self): @mock.patch("requests.Response.json") @mock.patch("requests.Session.post") - @mock.patch("sys.argv", ["ytmusicapi", "oauth", "--file", headers_oauth]) + @mock.patch("sys.argv", ["ytmusicapi", "oauth", "--file", oauth_filepath]) def test_setup_oauth(self, session_mock, json_mock): session_mock.return_value = Response() - json_mock.side_effect = [ - json.loads(config["auth"]["oauth_code"]), - json.loads(config["auth"]["oauth_token"]), - ] + fresh_token = self.yt_oauth._token.as_dict() + json_mock.side_effect = [blank_code, fresh_token] with mock.patch("builtins.input", return_value="y"): main() - self.assertTrue(Path(headers_oauth).exists()) + self.assertTrue(Path(oauth_filepath).exists()) json_mock.side_effect = None - with open(headers_oauth, mode="r", encoding="utf8") as headers: - string_headers = headers.read() - self.yt_oauth = YTMusic(string_headers) + with open(oauth_filepath, mode="r", encoding="utf8") as oauth_file: + string_oauth_token = oauth_file.read() + self.yt_oauth = YTMusic(string_oauth_token) + + ############### + # OAUTH + ############### + # 000 so test is run first and fresh token is available to others + def test_000_oauth_tokens(self): + # ensure instance initialized token + self.assertIsNotNone(self.yt_oauth._token) + + # set reference file + with open(oauth_filepath, 'r') as f: + first_json = json.load(f) + + # pull reference values from underlying token + first_token = self.yt_oauth._token.token.access_token + first_expire = self.yt_oauth._token.token.expires_at + # make token expire + self.yt_oauth._token.token._expires_at = time.time() + # check + self.assertTrue(self.yt_oauth._token.token.is_expiring) + # pull new values, assuming token will be refreshed on access + second_token = self.yt_oauth._token.access_token + second_expire = self.yt_oauth._token.token.expires_at + second_token_inner = self.yt_oauth._token.token.access_token + # check it was refreshed + self.assertNotEqual(first_token, second_token) + # check expiration timestamps to confirm + self.assertNotEqual(second_expire, first_expire) + self.assertGreater(second_expire, time.time() + 60) + # check token is propagating properly + self.assertEqual(second_token, second_token_inner) + + with open(oauth_filepath, 'r') as f2: + second_json = json.load(f2) + + # ensure token is updating local file + self.assertNotEqual(first_json, second_json) def test_alt_oauth(self): # ensure client works/ignores alt if browser credentials passed as auth self.assertFalse(self.yt_alt_oauth.is_alt_oauth) + with open(oauth_filepath, 'r') as f: + token_dict = json.load(f) # oauth token dict entry and alt - self.yt_alt_oauth = YTMusic(json.loads(config['auth']['oauth_token']), - oauth_credentials=alt_oauth_creds) + self.yt_alt_oauth = YTMusic(token_dict, oauth_credentials=alt_oauth_creds) self.assertTrue(self.yt_alt_oauth.is_alt_oauth) ############### @@ -509,7 +549,7 @@ def test_edit_playlist(self): ) self.assertEqual(response, "STATUS_SUCCEEDED", "Playlist edit failed") - # end to end test adding playlist, adding item, deleting item, deleting playlist + # end-to-end test adding playlist, adding item, deleting item, deleting playlist # @unittest.skip('You are creating too many playlists. Please wait a bit...') def test_end2end(self): playlist_id = self.yt_brand.create_playlist( From d05c011e420fb681a50af2e7025e85c5d66ae630 Mon Sep 17 00:00:00 2001 From: jcbirdwell Date: Thu, 28 Dec 2023 18:27:37 -0600 Subject: [PATCH 216/238] fix: not here not now --- ytmusicapi/auth/oauth/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ytmusicapi/auth/oauth/base.py b/ytmusicapi/auth/oauth/base.py index cd59ce19..751adcfa 100644 --- a/ytmusicapi/auth/oauth/base.py +++ b/ytmusicapi/auth/oauth/base.py @@ -47,7 +47,6 @@ def as_dict(self) -> RefreshableTokenDict: } def as_json(self) -> str: - # TODO: [PYDANTIC]: add a custom serializer return json.dumps(self.as_dict()) def as_auth(self) -> str: From 3abbbf820903ceaeedc9402986ac3d58840cacda Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 31 Dec 2023 10:43:26 +0100 Subject: [PATCH 217/238] fix review findings --- ytmusicapi/auth/headers.py | 1 - ytmusicapi/auth/oauth/base.py | 19 +++++++++++---- ytmusicapi/auth/oauth/models.py | 25 ++++++++++---------- ytmusicapi/auth/oauth/refreshing.py | 33 ++++---------------------- ytmusicapi/ytmusic.py | 36 +++++++++++------------------ 5 files changed, 45 insertions(+), 69 deletions(-) diff --git a/ytmusicapi/auth/headers.py b/ytmusicapi/auth/headers.py index 6831b8bb..c3d8165d 100644 --- a/ytmusicapi/auth/headers.py +++ b/ytmusicapi/auth/headers.py @@ -7,7 +7,6 @@ def load_headers_file(auth: str) -> Dict: if os.path.isfile(auth): with open(auth) as json_file: input_json = json.load(json_file) - input_json['filepath'] = auth else: input_json = json.loads(auth) return input_json diff --git a/ytmusicapi/auth/oauth/base.py b/ytmusicapi/auth/oauth/base.py index 751adcfa..980be2a3 100644 --- a/ytmusicapi/auth/oauth/base.py +++ b/ytmusicapi/auth/oauth/base.py @@ -1,7 +1,9 @@ -from typing import Optional, Any, Dict +from typing import Optional, Dict import time import json +from requests.structures import CaseInsensitiveDict + from .models import BaseTokenDict, DefaultScope, Bearer, RefreshableTokenDict @@ -63,8 +65,7 @@ def __init__(self, scope: str, token_type: str, expires_at: Optional[int] = None, - expires_in: Optional[int] = None, - **_: Optional[Any]): + expires_in: Optional[int] = None): """ :param access_token: active oauth key @@ -73,7 +74,6 @@ def __init__(self, :param token_type: commonly 'Bearer' :param expires_at: Optional. Unix epoch (seconds) of access token expiration. :param expires_in: Optional. Seconds till expiration, assumes/calculates epoch of init. - :param _: void excess kwargs """ # match baseclass attribute/property format @@ -85,6 +85,17 @@ def __init__(self, self._expires_at: int = expires_at if expires_at else int(time.time() + expires_in) self._token_type = token_type + @staticmethod + def is_oauth(headers: CaseInsensitiveDict) -> bool: + oauth_structure = { + "access_token", + "expires_at", + "expires_in", + "token_type", + "refresh_token", + } + return all(key in headers for key in oauth_structure) + def update(self, fresh_access: BaseTokenDict): """ Update access_token and expiration attributes with a BaseTokenDict inplace. diff --git a/ytmusicapi/auth/oauth/models.py b/ytmusicapi/auth/oauth/models.py index 7778bbf2..d301602c 100644 --- a/ytmusicapi/auth/oauth/models.py +++ b/ytmusicapi/auth/oauth/models.py @@ -1,8 +1,7 @@ -# stand-in classes for clarity until pydantic implementation +"""models for oauth authentication""" from typing import Union, Literal, TypedDict -# for static typechecking DefaultScope = Union[str, Literal['https://www.googleapis.com/auth/youtube']] Bearer = Union[str, Literal['Bearer']] @@ -10,24 +9,24 @@ class BaseTokenDict(TypedDict): """ Limited token. Does not provide a refresh token. Commonly obtained via a token refresh. """ - access_token: str # str to be used in Authorization header - expires_in: int # seconds until expiration from request timestamp - scope: DefaultScope # should be 'https://www.googleapis.com/auth/youtube' - token_type: Bearer # should be 'Bearer' + access_token: str #: str to be used in Authorization header + expires_in: int #: seconds until expiration from request timestamp + scope: DefaultScope #: should be 'https://www.googleapis.com/auth/youtube' + token_type: Bearer #: should be 'Bearer' class RefreshableTokenDict(BaseTokenDict): """ Entire token. Including refresh. Obtained through token setup. """ - expires_at: int # UNIX epoch timestamp in seconds - refresh_token: str # str used to obtain new access token upon expiration + expires_at: int #: UNIX epoch timestamp in seconds + refresh_token: str #: str used to obtain new access token upon expiration class AuthCodeDict(TypedDict): """ Keys for the json object obtained via code response during auth flow. """ - device_code: str # code obtained via user confirmation and oauth consent - user_code: str # alphanumeric code user is prompted to enter as confirmation. formatted as XXX-XXX-XXX. - expires_in: int # seconds from original request timestamp - interval: int # (?) "5" (?) - verification_url: str # base url for OAuth consent screen for user signin/confirmation + device_code: str #: code obtained via user confirmation and oauth consent + user_code: str #: alphanumeric code user is prompted to enter as confirmation. formatted as XXX-XXX-XXX. + expires_in: int #: seconds from original request timestamp + interval: int #: (?) "5" (?) + verification_url: str #: base url for OAuth consent screen for user signin/confirmation diff --git a/ytmusicapi/auth/oauth/refreshing.py b/ytmusicapi/auth/oauth/refreshing.py index 82df8d1f..0d77252f 100644 --- a/ytmusicapi/auth/oauth/refreshing.py +++ b/ytmusicapi/auth/oauth/refreshing.py @@ -43,11 +43,11 @@ def __init__(self, When provided, file contents is updated upon token refresh. """ - self.token: OAuthToken = token # internal token being used / refreshed / maintained - self.credentials = credentials # credentials used for access_token refreshing + self.token: OAuthToken = token #: internal token being used / refreshed / maintained + self.credentials = credentials #: credentials used for access_token refreshing - # protected/property attribute enables auto writing token - # values to new file location via setter + #: protected/property attribute enables auto writing token + # values to new file location via setter self._local_cache = local_cache @property @@ -81,31 +81,6 @@ def store_token(self, path: Optional[str] = None) -> None: with open(file_path, encoding="utf8", mode='w') as file: json.dump(self.token.as_dict(), file, indent=True) - @property - def refresh_token(self) -> str: - # pass underlying value - return self.token.refresh_token - - @property - def is_expiring(self) -> bool: - # Refreshing token never expires - return False - - @property - def expires_at(self) -> None: - # Refreshing token never expires - return None - - @property - def expires_in(self) -> None: - # Refreshing token never expires - return None - - @property - def scope(self) -> DefaultScope: - # pass underlying value - return self.token.scope - @property def token_type(self) -> Bearer: # pass underlying value diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 6198054f..922cb2a7 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -76,23 +76,23 @@ def __init__(self, used for authentication flow. """ - self._base_headers = None # for authless initializing requests during OAuth flow - self._headers = None # cache formed headers including auth + self._base_headers = None #: for authless initializing requests during OAuth flow + self._headers = None #: cache formed headers including auth - self.auth = auth # raw auth - self._input_dict = {} # parsed auth arg value in dictionary format + self.auth = auth #: raw auth + self._input_dict = {} #: parsed auth arg value in dictionary format # (?) may be better implemented as an auth_type attribute with a literal/enum value (?) - self.is_alt_oauth = False # YTM instance is using a non-default OAuth client (id & secret) - self.is_oauth_auth = False # client auth via OAuth token refreshing - self.is_browser_auth = False # authorization via extracted browser headers, enables uploading capabilities - self.is_custom_oauth = False # allows fully formed OAuth headers to ignore browser auth refresh flow + self.is_alt_oauth = False #: YTM instance is using a non-default OAuth client (id & secret) + self.is_oauth_auth = False #: client auth via OAuth token refreshing + self.is_browser_auth = False #: authorization via extracted browser headers, enables uploading capabilities + self.is_custom_oauth = False #: allows fully formed OAuth headers to ignore browser auth refresh flow - self._token: Token # OAuth credential handler - self.oauth_credentials: OAuthCredentials # Client used for OAuth refreshing + self._token: Token #: OAuth credential handler + self.oauth_credentials: OAuthCredentials #: Client used for OAuth refreshing - self._session: requests.Session # request session for connection pooling - self.proxies: Dict = proxies # params for session modification + self._session: requests.Session #: request session for connection pooling + self.proxies: Dict = proxies #: params for session modification if isinstance(requests_session, requests.Session): self._session = requests_session @@ -103,10 +103,7 @@ def __init__(self, else: # Use the Requests API module as a "session". self._session = requests.api - if oauth_credentials is not None: - self.oauth_credentials = oauth_credentials - else: - self.oauth_credentials = OAuthCredentials() + self.oauth_credentials = oauth_credentials if oauth_credentials is not None else OAuthCredentials() # see google cookie docs: https://policies.google.com/technologies/cookies # value from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L502 @@ -119,18 +116,13 @@ def __init__(self, else: self._input_dict = self.auth - # check if OAuth by passing values as kwargs to OAuthToken init using - # KeyError from incompatible input_dict as False - try: + if OAuthToken.is_oauth(self._input_dict): base_token = OAuthToken(**self._input_dict) self._token = RefreshingToken(base_token, self.oauth_credentials, self._input_dict.get('filepath')) self.is_oauth_auth = True self.is_alt_oauth = oauth_credentials is not None - except TypeError: - pass - # prepare context self.context = initialize_context() From c848e50b3bdb8d585880cb83abb163924d5f3a07 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 31 Dec 2023 11:18:39 +0100 Subject: [PATCH 218/238] refactor authtypes --- tests/test.py | 9 +++++---- ytmusicapi/auth/headers.py | 12 ------------ ytmusicapi/auth/types.py | 25 ++++++++++++++++++++++++ ytmusicapi/mixins/uploads.py | 3 ++- ytmusicapi/ytmusic.py | 37 ++++++++++++++++++------------------ 5 files changed, 50 insertions(+), 36 deletions(-) delete mode 100644 ytmusicapi/auth/headers.py create mode 100644 ytmusicapi/auth/types.py diff --git a/tests/test.py b/tests/test.py index 99f6e79f..d43e1000 100644 --- a/tests/test.py +++ b/tests/test.py @@ -8,6 +8,7 @@ from requests import Response +from ytmusicapi.auth.types import AuthType from ytmusicapi.setup import main, setup # noqa: E402 from ytmusicapi.ytmusic import YTMusic, OAuthCredentials # noqa: E402 from ytmusicapi.constants import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET @@ -82,7 +83,7 @@ def test_setup_oauth(self, session_mock, json_mock): # OAUTH ############### # 000 so test is run first and fresh token is available to others - def test_000_oauth_tokens(self): + def test_oauth_tokens(self): # ensure instance initialized token self.assertIsNotNone(self.yt_oauth._token) @@ -115,14 +116,14 @@ def test_000_oauth_tokens(self): # ensure token is updating local file self.assertNotEqual(first_json, second_json) - def test_alt_oauth(self): + def test_oauth_custom_client(self): # ensure client works/ignores alt if browser credentials passed as auth - self.assertFalse(self.yt_alt_oauth.is_alt_oauth) + self.assertNotEqual(self.yt_alt_oauth.auth_type, AuthType.OAUTH_CUSTOM_CLIENT) with open(oauth_filepath, 'r') as f: token_dict = json.load(f) # oauth token dict entry and alt self.yt_alt_oauth = YTMusic(token_dict, oauth_credentials=alt_oauth_creds) - self.assertTrue(self.yt_alt_oauth.is_alt_oauth) + self.assertEqual(self.yt_alt_oauth.auth_type, AuthType.OAUTH_CUSTOM_CLIENT) ############### # BROWSING diff --git a/ytmusicapi/auth/headers.py b/ytmusicapi/auth/headers.py deleted file mode 100644 index c3d8165d..00000000 --- a/ytmusicapi/auth/headers.py +++ /dev/null @@ -1,12 +0,0 @@ -import json -import os -from typing import Dict - - -def load_headers_file(auth: str) -> Dict: - if os.path.isfile(auth): - with open(auth) as json_file: - input_json = json.load(json_file) - else: - input_json = json.loads(auth) - return input_json diff --git a/ytmusicapi/auth/types.py b/ytmusicapi/auth/types.py new file mode 100644 index 00000000..106c0515 --- /dev/null +++ b/ytmusicapi/auth/types.py @@ -0,0 +1,25 @@ +"""enum representing types of authentication supported by this library""" + +from enum import Enum, auto +from typing import List + + +class AuthType(int, Enum): + """enum representing types of authentication supported by this library""" + + UNAUTHORIZED = auto() + + BROWSER = auto() + + #: client auth via OAuth token refreshing + OAUTH_DEFAULT = auto() + + #: YTM instance is using a non-default OAuth client (id & secret) + OAUTH_CUSTOM_CLIENT = auto() + + #: allows fully formed OAuth headers to ignore browser auth refresh flow + OAUTH_CUSTOM_FULL = auto() + + @classmethod + def oauth_types(cls) -> List["AuthType"]: + return [cls.OAUTH_DEFAULT, cls.OAUTH_CUSTOM_CLIENT, cls.OAUTH_CUSTOM_FULL] diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index e63bb19d..6465a9bc 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -10,6 +10,7 @@ from ytmusicapi.parsers.library import parse_library_albums, parse_library_artists, get_library_contents from ytmusicapi.parsers.albums import parse_album_header from ytmusicapi.parsers.uploads import parse_uploaded_items +from ..auth.types import AuthType class UploadsMixin: @@ -194,7 +195,7 @@ def upload_song(self, filepath: str) -> Union[str, requests.Response]: :return: Status String or full response """ self._check_auth() - if not self.is_browser_auth: + if not self.auth_type == AuthType.BROWSER: raise Exception("Please provide authentication before using this function") if not os.path.isfile(filepath): raise Exception("The provided file does not exist.") diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 922cb2a7..75bf9408 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -17,9 +17,9 @@ from ytmusicapi.mixins.playlists import PlaylistsMixin from ytmusicapi.mixins.uploads import UploadsMixin -from .auth.headers import load_headers_file from .auth.oauth import OAuthCredentials, RefreshingToken, OAuthToken from .auth.oauth.base import Token +from .auth.types import AuthType class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, @@ -82,11 +82,7 @@ def __init__(self, self.auth = auth #: raw auth self._input_dict = {} #: parsed auth arg value in dictionary format - # (?) may be better implemented as an auth_type attribute with a literal/enum value (?) - self.is_alt_oauth = False #: YTM instance is using a non-default OAuth client (id & secret) - self.is_oauth_auth = False #: client auth via OAuth token refreshing - self.is_browser_auth = False #: authorization via extracted browser headers, enables uploading capabilities - self.is_custom_oauth = False #: allows fully formed OAuth headers to ignore browser auth refresh flow + self.auth_type: AuthType = AuthType.UNAUTHORIZED self._token: Token #: OAuth credential handler self.oauth_credentials: OAuthCredentials #: Client used for OAuth refreshing @@ -103,14 +99,19 @@ def __init__(self, else: # Use the Requests API module as a "session". self._session = requests.api - self.oauth_credentials = oauth_credentials if oauth_credentials is not None else OAuthCredentials() - # see google cookie docs: https://policies.google.com/technologies/cookies # value from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L502 self.cookies = {'SOCS': 'CAI'} if self.auth is not None: + self.oauth_credentials = oauth_credentials if oauth_credentials is not None else OAuthCredentials() + auth_filepath = None if isinstance(self.auth, str): - input_json = load_headers_file(self.auth) + if os.path.isfile(auth): + with open(auth) as json_file: + auth_filepath = auth + input_json = json.load(json_file) + else: + input_json = json.loads(auth) self._input_dict = CaseInsensitiveDict(input_json) else: @@ -118,10 +119,8 @@ def __init__(self, if OAuthToken.is_oauth(self._input_dict): base_token = OAuthToken(**self._input_dict) - self._token = RefreshingToken(base_token, self.oauth_credentials, - self._input_dict.get('filepath')) - self.is_oauth_auth = True - self.is_alt_oauth = oauth_credentials is not None + self._token = RefreshingToken(base_token, self.oauth_credentials, auth_filepath) + self.auth_type = AuthType.OAUTH_CUSTOM_CLIENT if oauth_credentials else AuthType.OAUTH_DEFAULT # prepare context self.context = initialize_context() @@ -152,13 +151,13 @@ def __init__(self, auth_headers = self._input_dict.get("authorization") if auth_headers: if "SAPISIDHASH" in auth_headers: - self.is_browser_auth = True + self.auth_type = AuthType.BROWSER elif auth_headers.startswith('Bearer'): - self.is_custom_oauth = True + self.auth_type = AuthType.OAUTH_CUSTOM_FULL # sapsid, origin, and params all set once during init self.params = YTM_PARAMS - if self.is_browser_auth: + if self.auth_type == AuthType.BROWSER: self.params += YTM_PARAMS_KEY try: cookie = self.base_headers.get('cookie') @@ -170,7 +169,7 @@ def __init__(self, @property def base_headers(self): if not self._base_headers: - if self.is_browser_auth or self.is_custom_oauth: + if self.auth_type == AuthType.BROWSER or self.auth_type == AuthType.OAUTH_CUSTOM_FULL: self._base_headers = self._input_dict else: self._base_headers = { @@ -191,10 +190,10 @@ def headers(self): self._headers = self.base_headers # keys updated each use, custom oauth implementations left untouched - if self.is_browser_auth: + if self.auth_type == AuthType.BROWSER: self._headers["authorization"] = get_authorization(self.sapisid + ' ' + self.origin) - elif self.is_oauth_auth: + elif self.auth_type in AuthType.oauth_types(): self._headers['authorization'] = self._token.as_auth() self._headers['X-Goog-Request-Time'] = str(int(time.time())) From ec02876874f597b54651d7fb0087973d490a671c Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 31 Dec 2023 11:39:48 +0100 Subject: [PATCH 219/238] fix workflow --- .github/workflows/coverage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 8e5643aa..6ad8aac6 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -31,7 +31,7 @@ jobs: pip install -e . pip install coverage curl -o tests/test.mp3 https://www.kozco.com/tech/piano2-CoolEdit.mp3 - cat <<< "$HEADERS_AUTH" > tests/headers_auth.json + cat <<< "$HEADERS_AUTH" > tests/browser.json cat <<< "$TEST_CFG" > tests/test.cfg coverage run coverage xml From 1fd93dcfb657528ead5ff6b970bee7347ba43bd3 Mon Sep 17 00:00:00 2001 From: sigma67 <16363825+sigma67@users.noreply.github.com> Date: Sun, 31 Dec 2023 12:54:07 +0100 Subject: [PATCH 220/238] Ruff lint (#496) * add ruff, pdm * update .gitignore * format * test * check failure * fix constants.py --- .github/workflows/coverage.yml | 6 +- .github/workflows/lint.yml | 16 + .gitignore | 1 + docs/source/conf.py | 21 +- pdm.lock | 560 +++++++++++++++++++++++++++ pyproject.toml | 24 +- tests/test.py | 148 +++---- ytmusicapi/__init__.py | 11 +- ytmusicapi/auth/browser.py | 3 +- ytmusicapi/auth/oauth/__init__.py | 4 +- ytmusicapi/auth/oauth/base.py | 58 +-- ytmusicapi/auth/oauth/credentials.py | 58 +-- ytmusicapi/auth/oauth/models.py | 12 +- ytmusicapi/auth/oauth/refreshing.py | 17 +- ytmusicapi/constants.py | 28 +- ytmusicapi/continuations.py | 68 ++-- ytmusicapi/helpers.py | 43 +- ytmusicapi/mixins/_utils.py | 23 +- ytmusicapi/mixins/browsing.py | 147 ++++--- ytmusicapi/mixins/explore.py | 101 ++--- ytmusicapi/mixins/library.py | 132 ++++--- ytmusicapi/mixins/playlists.py | 277 ++++++------- ytmusicapi/mixins/search.py | 110 +++--- ytmusicapi/mixins/uploads.py | 121 +++--- ytmusicapi/mixins/watch.py | 92 +++-- ytmusicapi/navigation.py | 122 +++--- ytmusicapi/parsers/_utils.py | 48 +-- ytmusicapi/parsers/albums.py | 35 +- ytmusicapi/parsers/browsing.py | 112 +++--- ytmusicapi/parsers/explore.py | 35 +- ytmusicapi/parsers/i18n.py | 61 +-- ytmusicapi/parsers/library.py | 59 ++- ytmusicapi/parsers/playlists.py | 77 ++-- ytmusicapi/parsers/search.py | 222 ++++++----- ytmusicapi/parsers/songs.py | 52 ++- ytmusicapi/parsers/uploads.py | 37 +- ytmusicapi/parsers/watch.py | 41 +- ytmusicapi/setup.py | 21 +- ytmusicapi/ytmusic.py | 97 ++--- 39 files changed, 1866 insertions(+), 1234 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 pdm.lock diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6ad8aac6..a12c5208 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -2,11 +2,11 @@ name: Code coverage on: push: - branches: - - '**' - pull_request: branches: - master +# pull_request: +# branches: +# - master jobs: build: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..978318d7 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,16 @@ +name: Ruff + +on: + pull_request: + branches: + - master + +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: chartboost/ruff-action@v1 + - uses: chartboost/ruff-action@v1 + with: + args: format --check \ No newline at end of file diff --git a/.gitignore b/.gitignore index a9adcb05..c89cb371 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ venv*/ *.egg-info/ build dist +.pdm-python diff --git a/docs/source/conf.py b/docs/source/conf.py index c9dd88f8..cf2ccd45 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,17 +12,18 @@ # import os import sys -sys.path.insert(0, os.path.abspath('.')) -sys.path.insert(0, '../..') + +sys.path.insert(0, os.path.abspath(".")) +sys.path.insert(0, "../..") from ytmusicapi import __version__ # noqa: E402 -on_rtd = os.environ.get('READTHEDOCS', None) == 'True' +on_rtd = os.environ.get("READTHEDOCS", None) == "True" # -- Project information ----------------------------------------------------- -project = 'ytmusicapi' -copyright = '2022, sigma67' -author = 'sigma67' +project = "ytmusicapi" +copyright = "2022, sigma67" +author = "sigma67" # The full version, including alpha/beta/rc tags version = __version__ @@ -37,17 +38,17 @@ extensions = ["sphinx.ext.autodoc"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The master toctree document. -master_doc = 'index' +master_doc = "index" # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] -html_theme = "sphinx_rtd_theme" \ No newline at end of file +html_theme = "sphinx_rtd_theme" diff --git a/pdm.lock b/pdm.lock new file mode 100644 index 00000000..73114f5c --- /dev/null +++ b/pdm.lock @@ -0,0 +1,560 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default", "dev"] +strategy = ["cross_platform", "inherit_metadata"] +lock_version = "4.4.1" +content_hash = "sha256:e8454691de99746f0fe6ba42b53f33bd3f0b8928021df648441b496e8c9a2f11" + +[[package]] +name = "alabaster" +version = "0.7.13" +requires_python = ">=3.6" +summary = "A configurable sidebar-enabled Sphinx theme" +groups = ["dev"] +files = [ + {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, + {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, +] + +[[package]] +name = "babel" +version = "2.14.0" +requires_python = ">=3.7" +summary = "Internationalization utilities" +groups = ["dev"] +dependencies = [ + "pytz>=2015.7; python_version < \"3.9\"", +] +files = [ + {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, + {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, +] + +[[package]] +name = "certifi" +version = "2023.11.17" +requires_python = ">=3.6" +summary = "Python package for providing Mozilla's CA Bundle." +groups = ["default", "dev"] +files = [ + {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, + {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.3.2" +requires_python = ">=3.7.0" +summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +groups = ["default", "dev"] +files = [ + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +summary = "Cross-platform colored terminal text." +groups = ["dev"] +marker = "sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.4.0" +requires_python = ">=3.8" +summary = "Code coverage measurement for Python" +groups = ["dev"] +files = [ + {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"}, + {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"}, + {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"}, + {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"}, + {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"}, + {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"}, + {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"}, + {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"}, + {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"}, + {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"}, + {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"}, + {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"}, + {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"}, + {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"}, + {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"}, + {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"}, + {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"}, + {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"}, + {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"}, + {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"}, + {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"}, + {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"}, + {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"}, + {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"}, + {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"}, + {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"}, + {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"}, + {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"}, + {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"}, + {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"}, + {file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"}, + {file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"}, + {file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"}, + {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"}, + {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"}, + {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"}, + {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"}, + {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"}, + {file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"}, + {file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"}, + {file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"}, + {file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"}, + {file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"}, + {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"}, + {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"}, + {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"}, + {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"}, + {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"}, + {file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"}, + {file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"}, + {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"}, + {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, +] + +[[package]] +name = "docutils" +version = "0.19" +requires_python = ">=3.7" +summary = "Docutils -- Python Documentation Utilities" +groups = ["dev"] +files = [ + {file = "docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc"}, + {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"}, +] + +[[package]] +name = "idna" +version = "3.6" +requires_python = ">=3.5" +summary = "Internationalized Domain Names in Applications (IDNA)" +groups = ["default", "dev"] +files = [ + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, +] + +[[package]] +name = "imagesize" +version = "1.4.1" +requires_python = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +summary = "Getting image size from png/jpeg/jpeg2000/gif file" +groups = ["dev"] +files = [ + {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"}, + {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"}, +] + +[[package]] +name = "importlib-metadata" +version = "7.0.1" +requires_python = ">=3.8" +summary = "Read metadata from Python packages" +groups = ["dev"] +marker = "python_version < \"3.10\"" +dependencies = [ + "zipp>=0.5", +] +files = [ + {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, + {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, +] + +[[package]] +name = "jinja2" +version = "3.1.2" +requires_python = ">=3.7" +summary = "A very fast and expressive template engine." +groups = ["dev"] +dependencies = [ + "MarkupSafe>=2.0", +] +files = [ + {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, + {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, +] + +[[package]] +name = "markupsafe" +version = "2.1.3" +requires_python = ">=3.7" +summary = "Safely add untrusted strings to HTML/XML markup." +groups = ["dev"] +files = [ + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"}, + {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, + {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"}, + {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"}, + {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"}, + {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, +] + +[[package]] +name = "packaging" +version = "23.2" +requires_python = ">=3.7" +summary = "Core utilities for Python packages" +groups = ["dev"] +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "pygments" +version = "2.17.2" +requires_python = ">=3.7" +summary = "Pygments is a syntax highlighting package written in Python." +groups = ["dev"] +files = [ + {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, + {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, +] + +[[package]] +name = "pytz" +version = "2023.3.post1" +summary = "World timezone definitions, modern and historical" +groups = ["dev"] +marker = "python_version < \"3.9\"" +files = [ + {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, + {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, +] + +[[package]] +name = "requests" +version = "2.31.0" +requires_python = ">=3.7" +summary = "Python HTTP for Humans." +groups = ["default", "dev"] +dependencies = [ + "certifi>=2017.4.17", + "charset-normalizer<4,>=2", + "idna<4,>=2.5", + "urllib3<3,>=1.21.1", +] +files = [ + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, +] + +[[package]] +name = "ruff" +version = "0.1.9" +requires_python = ">=3.7" +summary = "An extremely fast Python linter and code formatter, written in Rust." +groups = ["dev"] +files = [ + {file = "ruff-0.1.9-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e6a212f436122ac73df851f0cf006e0c6612fe6f9c864ed17ebefce0eff6a5fd"}, + {file = "ruff-0.1.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:28d920e319783d5303333630dae46ecc80b7ba294aeffedf946a02ac0b7cc3db"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:104aa9b5e12cb755d9dce698ab1b97726b83012487af415a4512fedd38b1459e"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e63bf5a4a91971082a4768a0aba9383c12392d0d6f1e2be2248c1f9054a20da"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4d0738917c203246f3e275b37006faa3aa96c828b284ebfe3e99a8cb413c8c4b"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69dac82d63a50df2ab0906d97a01549f814b16bc806deeac4f064ff95c47ddf5"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2aec598fb65084e41a9c5d4b95726173768a62055aafb07b4eff976bac72a592"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:744dfe4b35470fa3820d5fe45758aace6269c578f7ddc43d447868cfe5078bcb"}, + {file = "ruff-0.1.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:479ca4250cab30f9218b2e563adc362bd6ae6343df7c7b5a7865300a5156d5a6"}, + {file = "ruff-0.1.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:aa8344310f1ae79af9ccd6e4b32749e93cddc078f9b5ccd0e45bd76a6d2e8bb6"}, + {file = "ruff-0.1.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:837c739729394df98f342319f5136f33c65286b28b6b70a87c28f59354ec939b"}, + {file = "ruff-0.1.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e6837202c2859b9f22e43cb01992373c2dbfeae5c0c91ad691a4a2e725392464"}, + {file = "ruff-0.1.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:331aae2cd4a0554667ac683243b151c74bd60e78fb08c3c2a4ac05ee1e606a39"}, + {file = "ruff-0.1.9-py3-none-win32.whl", hash = "sha256:8151425a60878e66f23ad47da39265fc2fad42aed06fb0a01130e967a7a064f4"}, + {file = "ruff-0.1.9-py3-none-win_amd64.whl", hash = "sha256:c497d769164df522fdaf54c6eba93f397342fe4ca2123a2e014a5b8fc7df81c7"}, + {file = "ruff-0.1.9-py3-none-win_arm64.whl", hash = "sha256:0e17f53bcbb4fff8292dfd84cf72d767b5e146f009cccd40c2fad27641f8a7a9"}, + {file = "ruff-0.1.9.tar.gz", hash = "sha256:b041dee2734719ddbb4518f762c982f2e912e7f28b8ee4fe1dee0b15d1b6e800"}, +] + +[[package]] +name = "snowballstemmer" +version = "2.2.0" +summary = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." +groups = ["dev"] +files = [ + {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"}, + {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"}, +] + +[[package]] +name = "sphinx" +version = "6.2.1" +requires_python = ">=3.8" +summary = "Python documentation generator" +groups = ["dev"] +dependencies = [ + "Jinja2>=3.0", + "Pygments>=2.13", + "alabaster<0.8,>=0.7", + "babel>=2.9", + "colorama>=0.4.5; sys_platform == \"win32\"", + "docutils<0.20,>=0.18.1", + "imagesize>=1.3", + "importlib-metadata>=4.8; python_version < \"3.10\"", + "packaging>=21.0", + "requests>=2.25.0", + "snowballstemmer>=2.0", + "sphinxcontrib-applehelp", + "sphinxcontrib-devhelp", + "sphinxcontrib-htmlhelp>=2.0.0", + "sphinxcontrib-jsmath", + "sphinxcontrib-qthelp", + "sphinxcontrib-serializinghtml>=1.1.5", +] +files = [ + {file = "Sphinx-6.2.1.tar.gz", hash = "sha256:6d56a34697bb749ffa0152feafc4b19836c755d90a7c59b72bc7dfd371b9cc6b"}, + {file = "sphinx-6.2.1-py3-none-any.whl", hash = "sha256:97787ff1fa3256a3eef9eda523a63dbf299f7b47e053cfcf684a1c2a8380c912"}, +] + +[[package]] +name = "sphinx-rtd-theme" +version = "2.0.0" +requires_python = ">=3.6" +summary = "Read the Docs theme for Sphinx" +groups = ["dev"] +dependencies = [ + "docutils<0.21", + "sphinx<8,>=5", + "sphinxcontrib-jquery<5,>=4", +] +files = [ + {file = "sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586"}, + {file = "sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b"}, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "1.0.4" +requires_python = ">=3.8" +summary = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" +groups = ["dev"] +files = [ + {file = "sphinxcontrib-applehelp-1.0.4.tar.gz", hash = "sha256:828f867945bbe39817c210a1abfd1bc4895c8b73fcaade56d45357a348a07d7e"}, + {file = "sphinxcontrib_applehelp-1.0.4-py3-none-any.whl", hash = "sha256:29d341f67fb0f6f586b23ad80e072c8e6ad0b48417db2bde114a4c9746feb228"}, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "1.0.2" +requires_python = ">=3.5" +summary = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." +groups = ["dev"] +files = [ + {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, + {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.0.1" +requires_python = ">=3.8" +summary = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" +groups = ["dev"] +files = [ + {file = "sphinxcontrib-htmlhelp-2.0.1.tar.gz", hash = "sha256:0cbdd302815330058422b98a113195c9249825d681e18f11e8b1f78a2f11efff"}, + {file = "sphinxcontrib_htmlhelp-2.0.1-py3-none-any.whl", hash = "sha256:c38cb46dccf316c79de6e5515e1770414b797162b23cd3d06e67020e1d2a6903"}, +] + +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +requires_python = ">=2.7" +summary = "Extension to include jQuery on newer Sphinx releases" +groups = ["dev"] +dependencies = [ + "Sphinx>=1.8", +] +files = [ + {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, + {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +requires_python = ">=3.5" +summary = "A sphinx extension which renders display math in HTML via JavaScript" +groups = ["dev"] +files = [ + {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"}, + {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"}, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "1.0.3" +requires_python = ">=3.5" +summary = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." +groups = ["dev"] +files = [ + {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, + {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "1.1.5" +requires_python = ">=3.5" +summary = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." +groups = ["dev"] +files = [ + {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, + {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, +] + +[[package]] +name = "urllib3" +version = "2.1.0" +requires_python = ">=3.8" +summary = "HTTP library with thread-safe connection pooling, file post, and more." +groups = ["default", "dev"] +files = [ + {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, + {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, +] + +[[package]] +name = "zipp" +version = "3.17.0" +requires_python = ">=3.8" +summary = "Backport of pathlib-compatible object wrapper for zip files" +groups = ["dev"] +marker = "python_version < \"3.10\"" +files = [ + {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, + {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, +] diff --git a/pyproject.toml b/pyproject.toml index cb28854e..314a7032 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,9 +13,6 @@ dependencies = [ ] dynamic = ["version", "readme"] -[project.optional-dependencies] -dev = ['pre-commit', 'flake8', 'yapf', 'coverage', 'sphinx', 'sphinx-rtd-theme'] - [project.scripts] ytmusicapi = "ytmusicapi.setup:main" @@ -28,8 +25,6 @@ repository = "https://github.com/sigma67/ytmusicapi" requires = ["setuptools>=65", "setuptools_scm[toml]>=7"] build-backend = "setuptools.build_meta" -[tool.setuptools_scm] - [tool.setuptools.dynamic] readme = {file = ["README.rst"]} @@ -39,9 +34,20 @@ include-package-data=false [tool.setuptools.package-data] "*" = ["**.rst", "**.py", "**.mo"] -[tool.yapf] -column_limit = 99 -split_before_arithmetic_operator = true - [tool.coverage.run] command_line = "-m unittest discover tests" + +[tool.ruff] +line-length = 110 +ignore = [ "F403", "F405", "F821", "E731" ] +extend-select = [ + "I", # isort +] + +[tool.pdm.dev-dependencies] +dev = [ + "coverage>=7.4.0", + 'sphinx<7', + 'sphinx-rtd-theme', + "ruff>=0.1.9", +] \ No newline at end of file diff --git a/tests/test.py b/tests/test.py index d43e1000..0e1825f0 100644 --- a/tests/test.py +++ b/tests/test.py @@ -9,9 +9,9 @@ from requests import Response from ytmusicapi.auth.types import AuthType -from ytmusicapi.setup import main, setup # noqa: E402 -from ytmusicapi.ytmusic import YTMusic, OAuthCredentials # noqa: E402 from ytmusicapi.constants import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET +from ytmusicapi.setup import main, setup # noqa: E402 +from ytmusicapi.ytmusic import OAuthCredentials, YTMusic # noqa: E402 def get_resource(file: str) -> str: @@ -30,7 +30,7 @@ def get_resource(file: str) -> str: "user_code": "", "expires_in": 1800, "interval": 5, - "verification_url": "https://www.google.com/device" + "verification_url": "https://www.google.com/device", } oauth_filepath = get_resource(config["auth"]["oauth_file"]) @@ -40,7 +40,6 @@ def get_resource(file: str) -> str: class TestYTMusic(unittest.TestCase): - @classmethod def setUpClass(cls): warnings.filterwarnings(action="ignore", message="unclosed", category=ResourceWarning) @@ -51,8 +50,7 @@ def setUpClass(cls): cls.yt_alt_oauth = YTMusic(browser_filepath, oauth_credentials=alt_oauth_creds) cls.yt_auth = YTMusic(browser_filepath, location="GB") cls.yt_brand = YTMusic(config["auth"]["headers"], config["auth"]["brand_account"]) - cls.yt_empty = YTMusic(config["auth"]["headers_empty"], - config["auth"]["brand_account_empty"]) + cls.yt_empty = YTMusic(config["auth"]["headers_empty"], config["auth"]["brand_account_empty"]) @mock.patch("sys.argv", ["ytmusicapi", "browser", "--file", browser_filepath]) def test_setup_browser(self): @@ -88,7 +86,7 @@ def test_oauth_tokens(self): self.assertIsNotNone(self.yt_oauth._token) # set reference file - with open(oauth_filepath, 'r') as f: + with open(oauth_filepath, "r") as f: first_json = json.load(f) # pull reference values from underlying token @@ -110,7 +108,7 @@ def test_oauth_tokens(self): # check token is propagating properly self.assertEqual(second_token, second_token_inner) - with open(oauth_filepath, 'r') as f2: + with open(oauth_filepath, "r") as f2: second_json = json.load(f2) # ensure token is updating local file @@ -119,7 +117,7 @@ def test_oauth_tokens(self): def test_oauth_custom_client(self): # ensure client works/ignores alt if browser credentials passed as auth self.assertNotEqual(self.yt_alt_oauth.auth_type, AuthType.OAUTH_CUSTOM_CLIENT) - with open(oauth_filepath, 'r') as f: + with open(oauth_filepath, "r") as f: token_dict = json.load(f) # oauth token dict entry and alt self.yt_alt_oauth = YTMusic(token_dict, oauth_credentials=alt_oauth_creds) @@ -154,87 +152,74 @@ def test_search_filters(self): query = "hip hop playlist" results = self.yt_auth.search(query, filter="songs") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'song' for item in results)) + self.assertTrue(all(item["resultType"] == "song" for item in results)) results = self.yt_auth.search(query, filter="videos") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'video' for item in results)) + self.assertTrue(all(item["resultType"] == "video" for item in results)) results = self.yt_auth.search(query, filter="albums", limit=40) self.assertGreater(len(results), 20) - self.assertTrue(all(item['resultType'] == 'album' for item in results)) + self.assertTrue(all(item["resultType"] == "album" for item in results)) results = self.yt_auth.search("project-2", filter="artists", ignore_spelling=True) self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'artist' for item in results)) + self.assertTrue(all(item["resultType"] == "artist" for item in results)) results = self.yt_auth.search("classical music", filter="playlists") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'playlist' for item in results)) + self.assertTrue(all(item["resultType"] == "playlist" for item in results)) results = self.yt_auth.search("clasical music", filter="playlists", ignore_spelling=True) self.assertGreater(len(results), 10) - results = self.yt_auth.search("clasic rock", - filter="community_playlists", - ignore_spelling=True) + results = self.yt_auth.search("clasic rock", filter="community_playlists", ignore_spelling=True) self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'playlist' for item in results)) + self.assertTrue(all(item["resultType"] == "playlist" for item in results)) results = self.yt_auth.search("hip hop", filter="featured_playlists") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'playlist' for item in results)) + self.assertTrue(all(item["resultType"] == "playlist" for item in results)) results = self.yt_auth.search("some user", filter="profiles") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'profile' for item in results)) + self.assertTrue(all(item["resultType"] == "profile" for item in results)) results = self.yt_auth.search(query, filter="podcasts") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'podcast' for item in results)) + self.assertTrue(all(item["resultType"] == "podcast" for item in results)) results = self.yt_auth.search(query, filter="episodes") self.assertGreater(len(results), 10) - self.assertTrue(all(item['resultType'] == 'episode' for item in results)) + self.assertTrue(all(item["resultType"] == "episode" for item in results)) def test_search_uploads(self): self.assertRaises( Exception, self.yt.search, - config['queries']['uploads_songs'], + config["queries"]["uploads_songs"], filter="songs", scope="uploads", limit=40, ) - results = self.yt_auth.search(config['queries']['uploads_songs'], - scope="uploads", - limit=40) + results = self.yt_auth.search(config["queries"]["uploads_songs"], scope="uploads", limit=40) self.assertGreater(len(results), 20) def test_search_library(self): - results = self.yt_oauth.search(config['queries']['library_any'], scope="library") + results = self.yt_oauth.search(config["queries"]["library_any"], scope="library") self.assertGreater(len(results), 5) - results = self.yt_alt_oauth.search(config['queries']['library_songs'], - filter="songs", - scope="library", - limit=40) + results = self.yt_alt_oauth.search( + config["queries"]["library_songs"], filter="songs", scope="library", limit=40 + ) self.assertGreater(len(results), 10) - results = self.yt_auth.search(config['queries']['library_albums'], - filter="albums", - scope="library", - limit=40) + results = self.yt_auth.search( + config["queries"]["library_albums"], filter="albums", scope="library", limit=40 + ) self.assertGreaterEqual(len(results), 4) - results = self.yt_auth.search(config['queries']['library_artists'], - filter="artists", - scope="library", - limit=40) + results = self.yt_auth.search( + config["queries"]["library_artists"], filter="artists", scope="library", limit=40 + ) self.assertGreaterEqual(len(results), 1) - results = self.yt_auth.search(config['queries']['library_playlists'], - filter="playlists", - scope="library") + results = self.yt_auth.search( + config["queries"]["library_playlists"], filter="playlists", scope="library" + ) self.assertGreaterEqual(len(results), 1) - self.assertRaises(Exception, - self.yt_auth.search, - "beatles", - filter="community_playlists", - scope="library", - limit=40) - self.assertRaises(Exception, - self.yt_auth.search, - "beatles", - filter="featured_playlists", - scope="library", - limit=40) + self.assertRaises( + Exception, self.yt_auth.search, "beatles", filter="community_playlists", scope="library", limit=40 + ) + self.assertRaises( + Exception, self.yt_auth.search, "beatles", filter="featured_playlists", scope="library", limit=40 + ) def test_get_artist(self): results = self.yt.get_artist("MPLAUCmMUZbaYdNH0bEd1PAlAqsA") @@ -243,10 +228,7 @@ def test_get_artist(self): # test correctness of related artists related = results["related"]["results"] self.assertEqual( - len([ - x for x in related - if set(x.keys()) == {"browseId", "subscribers", "title", "thumbnails"} - ]), + len([x for x in related if set(x.keys()) == {"browseId", "subscribers", "title", "thumbnails"}]), len(related), ) @@ -255,14 +237,12 @@ def test_get_artist(self): def test_get_artist_albums(self): artist = self.yt.get_artist("UCj5ZiBBqpe0Tg4zfKGHEFuQ") - results = self.yt.get_artist_albums(artist["albums"]["browseId"], - artist["albums"]["params"]) + results = self.yt.get_artist_albums(artist["albums"]["browseId"], artist["albums"]["params"]) self.assertGreater(len(results), 0) def test_get_artist_singles(self): artist = self.yt.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") - results = self.yt.get_artist_albums(artist["singles"]["browseId"], - artist["singles"]["params"]) + results = self.yt.get_artist_albums(artist["singles"]["browseId"], artist["singles"]["params"]) self.assertGreater(len(results), 0) def test_get_user(self): @@ -271,8 +251,7 @@ def test_get_user(self): def test_get_user_playlists(self): results = self.yt.get_user("UCPVhZsC2od1xjGhgEc2NEPQ") - results = self.yt.get_user_playlists("UCPVhZsC2od1xjGhgEc2NEPQ", - results["playlists"]["params"]) + results = self.yt.get_user_playlists("UCPVhZsC2od1xjGhgEc2NEPQ", results["playlists"]["params"]) self.assertGreater(len(results), 100) def test_get_album_browse_id(self): @@ -280,9 +259,8 @@ def test_get_album_browse_id(self): browse_id = self.yt.get_album_browse_id("OLAK5uy_nMr9h2VlS-2PULNz3M3XVXQj_P3C2bqaY") self.assertEqual(browse_id, sample_album) with self.subTest(): - escaped_browse_id = self.yt.get_album_browse_id( - "OLAK5uy_nbMYyrfeg5ZgknoOsOGBL268hGxtcbnDM") - self.assertEqual(escaped_browse_id, 'MPREb_scJdtUCpPE2') + escaped_browse_id = self.yt.get_album_browse_id("OLAK5uy_nbMYyrfeg5ZgknoOsOGBL268hGxtcbnDM") + self.assertEqual(escaped_browse_id, "MPREb_scJdtUCpPE2") def test_get_album(self): results = self.yt_auth.get_album(sample_album) @@ -380,12 +358,10 @@ def test_get_watch_playlist(self): self.assertGreater(len(playlist["tracks"]), 45) playlist = self.yt_oauth.get_watch_playlist("UoAf_y9Ok4k") # private track self.assertGreaterEqual(len(playlist["tracks"]), 25) - playlist = self.yt.get_watch_playlist(playlistId=config['albums']['album_browse_id'], - shuffle=True) - self.assertEqual(len(playlist["tracks"]), config.getint('albums', 'album_track_length')) - playlist = self.yt_brand.get_watch_playlist(playlistId=config["playlists"]["own"], - shuffle=True) - self.assertEqual(len(playlist["tracks"]), config.getint('playlists', 'own_length')) + playlist = self.yt.get_watch_playlist(playlistId=config["albums"]["album_browse_id"], shuffle=True) + self.assertEqual(len(playlist["tracks"]), config.getint("albums", "album_track_length")) + playlist = self.yt_brand.get_watch_playlist(playlistId=config["playlists"]["own"], shuffle=True) + self.assertEqual(len(playlist["tracks"]), config.getint("playlists", "own_length")) ################ # LIBRARY @@ -473,13 +449,11 @@ def test_rate_song(self): def test_edit_song_library_status(self): album = self.yt_brand.get_album(sample_album) - response = self.yt_brand.edit_song_library_status( - album["tracks"][0]["feedbackTokens"]["add"]) + response = self.yt_brand.edit_song_library_status(album["tracks"][0]["feedbackTokens"]["add"]) album = self.yt_brand.get_album(sample_album) self.assertTrue(album["tracks"][0]["inLibrary"]) self.assertTrue(response["feedbackResponses"][0]["isProcessed"]) - response = self.yt_brand.edit_song_library_status( - album["tracks"][0]["feedbackTokens"]["remove"]) + response = self.yt_brand.edit_song_library_status(album["tracks"][0]["feedbackTokens"]["remove"]) album = self.yt_brand.get_album(sample_album) self.assertFalse(album["tracks"][0]["inLibrary"]) self.assertTrue(response["feedbackResponses"][0]["isProcessed"]) @@ -487,8 +461,7 @@ def test_edit_song_library_status(self): def test_rate_playlist(self): response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", "LIKE") self.assertIn("actions", response) - response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", - "INDIFFERENT") + response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", "INDIFFERENT") self.assertIn("actions", response) def test_subscribe_artists(self): @@ -501,26 +474,20 @@ def test_subscribe_artists(self): def test_get_playlist_foreign(self): self.assertRaises(Exception, self.yt.get_playlist, "PLABC") - playlist = self.yt.get_playlist("PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", - limit=300, - suggestions_limit=7) - self.assertGreater(len(playlist['duration']), 5) + playlist = self.yt.get_playlist("PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", limit=300, suggestions_limit=7) + self.assertGreater(len(playlist["duration"]), 5) self.assertGreater(len(playlist["tracks"]), 200) self.assertNotIn("suggestions", playlist) self.yt.get_playlist("RDATgXd-") self.assertGreaterEqual(len(playlist["tracks"]), 100) - playlist = self.yt_oauth.get_playlist("PLj4BSJLnVpNyIjbCWXWNAmybc97FXLlTk", - limit=None, - related=True) + playlist = self.yt_oauth.get_playlist("PLj4BSJLnVpNyIjbCWXWNAmybc97FXLlTk", limit=None, related=True) self.assertGreater(len(playlist["tracks"]), 200) self.assertEqual(len(playlist["related"]), 0) def test_get_playlist_owned(self): - playlist = self.yt_brand.get_playlist(config["playlists"]["own"], - related=True, - suggestions_limit=21) + playlist = self.yt_brand.get_playlist(config["playlists"]["own"], related=True, suggestions_limit=21) self.assertLess(len(playlist["tracks"]), 100) self.assertEqual(len(playlist["suggestions"]), 21) self.assertEqual(len(playlist["related"]), 10) @@ -624,8 +591,7 @@ def test_get_library_upload_album(self): self.assertGreater(len(album["tracks"]), 0) def test_get_library_upload_artist(self): - tracks = self.yt_oauth.get_library_upload_artist(config["uploads"]["private_artist_id"], - 100) + tracks = self.yt_oauth.get_library_upload_artist(config["uploads"]["private_artist_id"], 100) self.assertGreater(len(tracks), 0) diff --git a/ytmusicapi/__init__.py b/ytmusicapi/__init__.py index 63322764..4f3ce510 100644 --- a/ytmusicapi/__init__.py +++ b/ytmusicapi/__init__.py @@ -1,6 +1,7 @@ -from ytmusicapi.ytmusic import YTMusic +from importlib.metadata import PackageNotFoundError, version + from ytmusicapi.setup import setup, setup_oauth -from importlib.metadata import version, PackageNotFoundError +from ytmusicapi.ytmusic import YTMusic try: __version__ = version("ytmusicapi") @@ -8,7 +9,7 @@ # package is not installed pass -__copyright__ = 'Copyright 2023 sigma67' -__license__ = 'MIT' -__title__ = 'ytmusicapi' +__copyright__ = "Copyright 2023 sigma67" +__license__ = "MIT" +__title__ = "ytmusicapi" __all__ = ["YTMusic", "setup_oauth", "setup"] diff --git a/ytmusicapi/auth/browser.py b/ytmusicapi/auth/browser.py index 6f55218f..34dda063 100644 --- a/ytmusicapi/auth/browser.py +++ b/ytmusicapi/auth/browser.py @@ -50,7 +50,8 @@ def setup_browser(filepath=None, headers_raw=None): missing_headers = {"cookie", "x-goog-authuser"} - set(k.lower() for k in user_headers.keys()) if missing_headers: raise Exception( - "The following entries are missing in your headers: " + ", ".join(missing_headers) + "The following entries are missing in your headers: " + + ", ".join(missing_headers) + ". Please try a different request (such as /browse) and make sure you are logged in." ) diff --git a/ytmusicapi/auth/oauth/__init__.py b/ytmusicapi/auth/oauth/__init__.py index d0b86c6a..f84e63fe 100644 --- a/ytmusicapi/auth/oauth/__init__.py +++ b/ytmusicapi/auth/oauth/__init__.py @@ -1,5 +1,5 @@ +from .base import OAuthToken from .credentials import OAuthCredentials from .refreshing import RefreshingToken -from .base import OAuthToken -__all__ = ['OAuthCredentials', 'RefreshingToken', 'OAuthToken'] +__all__ = ["OAuthCredentials", "RefreshingToken", "OAuthToken"] diff --git a/ytmusicapi/auth/oauth/base.py b/ytmusicapi/auth/oauth/base.py index 980be2a3..c92c4362 100644 --- a/ytmusicapi/auth/oauth/base.py +++ b/ytmusicapi/auth/oauth/base.py @@ -1,14 +1,15 @@ -from typing import Optional, Dict -import time import json +import time +from typing import Dict, Optional from requests.structures import CaseInsensitiveDict -from .models import BaseTokenDict, DefaultScope, Bearer, RefreshableTokenDict +from .models import BaseTokenDict, Bearer, DefaultScope, RefreshableTokenDict class Credentials: - """ Base class representation of YouTubeMusicAPI OAuth Credentials """ + """Base class representation of YouTubeMusicAPI OAuth Credentials""" + client_id: str client_secret: str @@ -23,7 +24,8 @@ def refresh_token(self, refresh_token: str) -> BaseTokenDict: class Token: - """ Base class representation of the YouTubeMusicAPI OAuth token. """ + """Base class representation of the YouTubeMusicAPI OAuth token.""" + access_token: str refresh_token: str expires_in: int @@ -34,38 +36,40 @@ class Token: token_type: Bearer def __repr__(self) -> str: - """ Readable version. """ - return f'{self.__class__.__name__}: {self.as_dict()}' + """Readable version.""" + return f"{self.__class__.__name__}: {self.as_dict()}" def as_dict(self) -> RefreshableTokenDict: - """ Returns dictionary containing underlying token values. """ + """Returns dictionary containing underlying token values.""" return { - 'access_token': self.access_token, - 'refresh_token': self.refresh_token, - 'scope': self.scope, - 'expires_at': self.expires_at, - 'expires_in': self.expires_in, - 'token_type': self.token_type + "access_token": self.access_token, + "refresh_token": self.refresh_token, + "scope": self.scope, + "expires_at": self.expires_at, + "expires_in": self.expires_in, + "token_type": self.token_type, } def as_json(self) -> str: return json.dumps(self.as_dict()) def as_auth(self) -> str: - """ Returns Authorization header ready str of token_type and access_token. """ - return f'{self.token_type} {self.access_token}' + """Returns Authorization header ready str of token_type and access_token.""" + return f"{self.token_type} {self.access_token}" class OAuthToken(Token): - """ Wrapper for an OAuth token implementing expiration methods. """ - - def __init__(self, - access_token: str, - refresh_token: str, - scope: str, - token_type: str, - expires_at: Optional[int] = None, - expires_in: Optional[int] = None): + """Wrapper for an OAuth token implementing expiration methods.""" + + def __init__( + self, + access_token: str, + refresh_token: str, + scope: str, + token_type: str, + expires_at: Optional[int] = None, + expires_in: Optional[int] = None, + ): """ :param access_token: active oauth key @@ -102,8 +106,8 @@ def update(self, fresh_access: BaseTokenDict): expires_at attribute set using current epoch, avoid expiration desync by passing only recently requested tokens dicts or updating values to compensate. """ - self._access_token = fresh_access['access_token'] - self._expires_at = int(time.time() + fresh_access['expires_in']) + self._access_token = fresh_access["access_token"] + self._expires_at = int(time.time() + fresh_access["expires_in"]) @property def access_token(self) -> str: diff --git a/ytmusicapi/auth/oauth/credentials.py b/ytmusicapi/auth/oauth/credentials.py index 2afef4cd..bdf12f76 100644 --- a/ytmusicapi/auth/oauth/credentials.py +++ b/ytmusicapi/auth/oauth/credentials.py @@ -3,13 +3,19 @@ import requests -from .models import RefreshableTokenDict, BaseTokenDict, AuthCodeDict -from .base import OAuthToken, Credentials -from .refreshing import RefreshingToken +from ytmusicapi.constants import ( + OAUTH_CLIENT_ID, + OAUTH_CLIENT_SECRET, + OAUTH_CODE_URL, + OAUTH_SCOPE, + OAUTH_TOKEN_URL, + OAUTH_USER_AGENT, +) + +from .base import Credentials, OAuthToken from .exceptions import BadOAuthClient, UnauthorizedOAuthClient - -from ytmusicapi.constants import (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_CODE_URL, - OAUTH_SCOPE, OAUTH_TOKEN_URL, OAUTH_USER_AGENT) +from .models import AuthCodeDict, BaseTokenDict, RefreshableTokenDict +from .refreshing import RefreshingToken class OAuthCredentials(Credentials): @@ -17,11 +23,13 @@ class OAuthCredentials(Credentials): Class for handling OAuth credential retrieval and refreshing. """ - def __init__(self, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - session: Optional[requests.Session] = None, - proxies: Optional[Dict] = None): + def __init__( + self, + client_id: Optional[str] = None, + client_secret: Optional[str] = None, + session: Optional[requests.Session] = None, + proxies: Optional[Dict] = None, + ): """ :param client_id: Optional. Set the GoogleAPI client_id used for auth flows. Requires client_secret also be provided if set. @@ -32,7 +40,7 @@ def __init__(self, # id, secret should be None, None or str, str if not isinstance(client_id, type(client_secret)): raise KeyError( - 'OAuthCredential init failure. Provide both client_id and client_secret or neither.' + "OAuthCredential init failure. Provide both client_id and client_secret or neither." ) # bind instance to OAuth client for auth flows @@ -44,34 +52,34 @@ def __init__(self, self._session.proxies.update(proxies) def get_code(self) -> AuthCodeDict: - """ Method for obtaining a new user auth code. First step of token creation. """ + """Method for obtaining a new user auth code. First step of token creation.""" code_response = self._send_request(OAUTH_CODE_URL, data={"scope": OAUTH_SCOPE}) return code_response.json() def _send_request(self, url, data): - """ Method for sending post requests with required client_id and User-Agent modifications """ + """Method for sending post requests with required client_id and User-Agent modifications""" data.update({"client_id": self.client_id}) response = self._session.post(url, data, headers={"User-Agent": OAUTH_USER_AGENT}) if response.status_code == 401: data = response.json() - issue = data.get('error') - if issue == 'unauthorized_client': - raise UnauthorizedOAuthClient( - 'Token refresh error. Most likely client/token mismatch.') + issue = data.get("error") + if issue == "unauthorized_client": + raise UnauthorizedOAuthClient("Token refresh error. Most likely client/token mismatch.") - elif issue == 'invalid_client': + elif issue == "invalid_client": raise BadOAuthClient( - 'OAuth client failure. Most likely client_id and client_secret mismatch or ' - 'YouTubeData API is not enabled.') + "OAuth client failure. Most likely client_id and client_secret mismatch or " + "YouTubeData API is not enabled." + ) else: raise Exception( - f'OAuth request error. status_code: {response.status_code}, url: {url}, content: {data}' + f"OAuth request error. status_code: {response.status_code}, url: {url}, content: {data}" ) return response def token_from_code(self, device_code: str) -> RefreshableTokenDict: - """ Method for verifying user auth code and conversion into a FullTokenDict. """ + """Method for verifying user auth code and conversion into a FullTokenDict.""" response = self._send_request( OAUTH_TOKEN_URL, data={ @@ -82,9 +90,7 @@ def token_from_code(self, device_code: str) -> RefreshableTokenDict: ) return response.json() - def prompt_for_token(self, - open_browser: bool = False, - to_file: Optional[str] = None) -> RefreshingToken: + def prompt_for_token(self, open_browser: bool = False, to_file: Optional[str] = None) -> RefreshingToken: """ Method for CLI token creation via user inputs. diff --git a/ytmusicapi/auth/oauth/models.py b/ytmusicapi/auth/oauth/models.py index d301602c..b22e802c 100644 --- a/ytmusicapi/auth/oauth/models.py +++ b/ytmusicapi/auth/oauth/models.py @@ -1,13 +1,13 @@ """models for oauth authentication""" -from typing import Union, Literal, TypedDict +from typing import Literal, TypedDict, Union -DefaultScope = Union[str, Literal['https://www.googleapis.com/auth/youtube']] -Bearer = Union[str, Literal['Bearer']] +DefaultScope = Union[str, Literal["https://www.googleapis.com/auth/youtube"]] +Bearer = Union[str, Literal["Bearer"]] class BaseTokenDict(TypedDict): - """ Limited token. Does not provide a refresh token. Commonly obtained via a token refresh. """ + """Limited token. Does not provide a refresh token. Commonly obtained via a token refresh.""" access_token: str #: str to be used in Authorization header expires_in: int #: seconds until expiration from request timestamp @@ -16,14 +16,14 @@ class BaseTokenDict(TypedDict): class RefreshableTokenDict(BaseTokenDict): - """ Entire token. Including refresh. Obtained through token setup. """ + """Entire token. Including refresh. Obtained through token setup.""" expires_at: int #: UNIX epoch timestamp in seconds refresh_token: str #: str used to obtain new access token upon expiration class AuthCodeDict(TypedDict): - """ Keys for the json object obtained via code response during auth flow. """ + """Keys for the json object obtained via code response during auth flow.""" device_code: str #: code obtained via user confirmation and oauth consent user_code: str #: alphanumeric code user is prompted to enter as confirmation. formatted as XXX-XXX-XXX. diff --git a/ytmusicapi/auth/oauth/refreshing.py b/ytmusicapi/auth/oauth/refreshing.py index 0d77252f..2d48e8a5 100644 --- a/ytmusicapi/auth/oauth/refreshing.py +++ b/ytmusicapi/auth/oauth/refreshing.py @@ -1,9 +1,9 @@ -from typing import Optional -import os import json +import os +from typing import Optional -from .base import OAuthToken, Token, Credentials -from .models import RefreshableTokenDict, Bearer, DefaultScope +from .base import Credentials, OAuthToken, Token +from .models import Bearer, RefreshableTokenDict class RefreshingToken(Token): @@ -32,10 +32,7 @@ def from_file(cls, file_path: str, credentials: Credentials, sync=True): return cls(OAuthToken(**file_pack), credentials, file_path if sync else None) - def __init__(self, - token: OAuthToken, - credentials: Credentials, - local_cache: Optional[str] = None): + def __init__(self, token: OAuthToken, credentials: Credentials, local_cache: Optional[str] = None): """ :param token: Underlying Token being maintained. :param credentials: OAuth client being used for refreshing. @@ -56,7 +53,7 @@ def local_cache(self) -> str | None: @local_cache.setter def local_cache(self, path: str): - """ Update attribute and dump token to new path. """ + """Update attribute and dump token to new path.""" self._local_cache = path self.store_token() @@ -78,7 +75,7 @@ def store_token(self, path: Optional[str] = None) -> None: file_path = path if path else self.local_cache if file_path: - with open(file_path, encoding="utf8", mode='w') as file: + with open(file_path, encoding="utf8", mode="w") as file: json.dump(self.token.as_dict(), file, indent=True) @property diff --git a/ytmusicapi/constants.py b/ytmusicapi/constants.py index 86d8eeb0..30926806 100644 --- a/ytmusicapi/constants.py +++ b/ytmusicapi/constants.py @@ -1,21 +1,23 @@ -YTM_DOMAIN = 'https://music.youtube.com' -YTM_BASE_API = YTM_DOMAIN + '/youtubei/v1/' -YTM_PARAMS = '?alt=json' +YTM_DOMAIN = "https://music.youtube.com" +YTM_BASE_API = YTM_DOMAIN + "/youtubei/v1/" +YTM_PARAMS = "?alt=json" YTM_PARAMS_KEY = "&key=AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" -USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0' +USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0" +# fmt: off SUPPORTED_LANGUAGES = { - 'ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'ja', 'ko', 'nl', 'pt', 'ru', 'tr', 'ur', 'zh_CN', - 'zh_TW' + "ar", "de", "en", "es", "fr", "hi", "it", "ja", "ko", "nl", "pt", "ru", "tr", "ur", "zh_CN", + "zh_TW" } SUPPORTED_LOCATIONS = { - 'AE', 'AR', 'AT', 'AU', 'AZ', 'BA', 'BD', 'BE', 'BG', 'BH', 'BO', 'BR', 'BY', 'CA', 'CH', 'CL', - 'CO', 'CR', 'CY', 'CZ', 'DE', 'DK', 'DO', 'DZ', 'EC', 'EE', 'EG', 'ES', 'FI', 'FR', 'GB', 'GE', - 'GH', 'GR', 'GT', 'HK', 'HN', 'HR', 'HU', 'ID', 'IE', 'IL', 'IN', 'IQ', 'IS', 'IT', 'JM', 'JO', - 'JP', 'KE', 'KH', 'KR', 'KW', 'KZ', 'LA', 'LB', 'LI', 'LK', 'LT', 'LU', 'LV', 'LY', 'MA', 'ME', - 'MK', 'MT', 'MX', 'MY', 'NG', 'NI', 'NL', 'NO', 'NP', 'NZ', 'OM', 'PA', 'PE', 'PG', 'PH', 'PK', - 'PL', 'PR', 'PT', 'PY', 'QA', 'RO', 'RS', 'RU', 'SA', 'SE', 'SG', 'SI', 'SK', 'SN', 'SV', 'TH', - 'TN', 'TR', 'TW', 'TZ', 'UA', 'UG', 'US', 'UY', 'VE', 'VN', 'YE', 'ZA', 'ZW' + "AE", "AR", "AT", "AU", "AZ", "BA", "BD", "BE", "BG", "BH", "BO", "BR", "BY", "CA", "CH", "CL", + "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "DZ", "EC", "EE", "EG", "ES", "FI", "FR", "GB", "GE", + "GH", "GR", "GT", "HK", "HN", "HR", "HU", "ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", + "JP", "KE", "KH", "KR", "KW", "KZ", "LA", "LB", "LI", "LK", "LT", "LU", "LV", "LY", "MA", "ME", + "MK", "MT", "MX", "MY", "NG", "NI", "NL", "NO", "NP", "NZ", "OM", "PA", "PE", "PG", "PH", "PK", + "PL", "PR", "PT", "PY", "QA", "RO", "RS", "RU", "SA", "SE", "SG", "SI", "SK", "SN", "SV", "TH", + "TN", "TR", "TW", "TZ", "UA", "UG", "US", "UY", "VE", "VN", "YE", "ZA", "ZW" } +# fmt: on OAUTH_CLIENT_ID = "861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com" OAUTH_CLIENT_SECRET = "SboVhoG9s0rNafixCSGGKXAT" OAUTH_SCOPE = "https://www.googleapis.com/auth/youtube" diff --git a/ytmusicapi/continuations.py b/ytmusicapi/continuations.py index f0793941..20497983 100644 --- a/ytmusicapi/continuations.py +++ b/ytmusicapi/continuations.py @@ -1,20 +1,19 @@ from ytmusicapi.navigation import nav -def get_continuations(results, - continuation_type, - limit, - request_func, - parse_func, - ctoken_path="", - reloadable=False): +def get_continuations( + results, continuation_type, limit, request_func, parse_func, ctoken_path="", reloadable=False +): items = [] - while 'continuations' in results and (limit is None or len(items) < limit): - additionalParams = get_reloadable_continuation_params(results) if reloadable \ + while "continuations" in results and (limit is None or len(items) < limit): + additionalParams = ( + get_reloadable_continuation_params(results) + if reloadable else get_continuation_params(results, ctoken_path) + ) response = request_func(additionalParams) - if 'continuationContents' in response: - results = response['continuationContents'][continuation_type] + if "continuationContents" in response: + results = response["continuationContents"][continuation_type] else: break contents = get_continuation_contents(results, parse_func) @@ -25,42 +24,38 @@ def get_continuations(results, return items -def get_validated_continuations(results, - continuation_type, - limit, - per_page, - request_func, - parse_func, - ctoken_path=""): +def get_validated_continuations( + results, continuation_type, limit, per_page, request_func, parse_func, ctoken_path="" +): items = [] - while 'continuations' in results and len(items) < limit: + while "continuations" in results and len(items) < limit: additionalParams = get_continuation_params(results, ctoken_path) wrapped_parse_func = lambda raw_response: get_parsed_continuation_items( - raw_response, parse_func, continuation_type) + raw_response, parse_func, continuation_type + ) validate_func = lambda parsed: validate_response(parsed, per_page, limit, len(items)) - response = resend_request_until_parsed_response_is_valid(request_func, additionalParams, - wrapped_parse_func, validate_func, - 3) - results = response['results'] - items.extend(response['parsed']) + response = resend_request_until_parsed_response_is_valid( + request_func, additionalParams, wrapped_parse_func, validate_func, 3 + ) + results = response["results"] + items.extend(response["parsed"]) return items def get_parsed_continuation_items(response, parse_func, continuation_type): - results = response['continuationContents'][continuation_type] - return {'results': results, 'parsed': get_continuation_contents(results, parse_func)} + results = response["continuationContents"][continuation_type] + return {"results": results, "parsed": get_continuation_contents(results, parse_func)} -def get_continuation_params(results, ctoken_path=''): - ctoken = nav(results, - ['continuations', 0, 'next' + ctoken_path + 'ContinuationData', 'continuation']) +def get_continuation_params(results, ctoken_path=""): + ctoken = nav(results, ["continuations", 0, "next" + ctoken_path + "ContinuationData", "continuation"]) return get_continuation_string(ctoken) def get_reloadable_continuation_params(results): - ctoken = nav(results, ['continuations', 0, 'reloadContinuationData', 'continuation']) + ctoken = nav(results, ["continuations", 0, "reloadContinuationData", "continuation"]) return get_continuation_string(ctoken) @@ -69,22 +64,23 @@ def get_continuation_string(ctoken): def get_continuation_contents(continuation, parse_func): - for term in ['contents', 'items']: + for term in ["contents", "items"]: if term in continuation: return parse_func(continuation[term]) return [] -def resend_request_until_parsed_response_is_valid(request_func, request_additional_params, - parse_func, validate_func, max_retries): +def resend_request_until_parsed_response_is_valid( + request_func, request_additional_params, parse_func, validate_func, max_retries +): response = request_func(request_additional_params) parsed_object = parse_func(response) retry_counter = 0 while not validate_func(parsed_object) and retry_counter < max_retries: response = request_func(request_additional_params) attempt = parse_func(response) - if len(attempt['parsed']) > len(parsed_object['parsed']): + if len(attempt["parsed"]) > len(parsed_object["parsed"]): parsed_object = attempt retry_counter += 1 @@ -96,4 +92,4 @@ def validate_response(response, per_page, limit, current_count): expected_items_count = min(per_page, remaining_items_count) # response is invalid, if it has less items then minimal expected count - return len(response['parsed']) >= expected_items_count + return len(response["parsed"]) >= expected_items_count diff --git a/ytmusicapi/helpers.py b/ytmusicapi/helpers.py index 9432a807..40eec562 100644 --- a/ytmusicapi/helpers.py +++ b/ytmusicapi/helpers.py @@ -1,11 +1,10 @@ -import re import json -from http.cookies import SimpleCookie -from hashlib import sha1 -import time import locale - +import re +import time import unicodedata +from hashlib import sha1 +from http.cookies import SimpleCookie from ytmusicapi.constants import * @@ -17,36 +16,36 @@ def initialize_headers(): "accept-encoding": "gzip, deflate", "content-type": "application/json", "content-encoding": "gzip", - "origin": YTM_DOMAIN + "origin": YTM_DOMAIN, } def initialize_context(): return { - 'context': { - 'client': { - 'clientName': 'WEB_REMIX', - 'clientVersion': '1.' + time.strftime("%Y%m%d", time.gmtime()) + '.01.00' + "context": { + "client": { + "clientName": "WEB_REMIX", + "clientVersion": "1." + time.strftime("%Y%m%d", time.gmtime()) + ".01.00", }, - 'user': {} + "user": {}, } } def get_visitor_id(request_func): response = request_func(YTM_DOMAIN) - matches = re.findall(r'ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;', response.text) + matches = re.findall(r"ytcfg\.set\s*\(\s*({.+?})\s*\)\s*;", response.text) visitor_id = "" if len(matches) > 0: ytcfg = json.loads(matches[0]) - visitor_id = ytcfg.get('VISITOR_DATA') - return {'X-Goog-Visitor-Id': visitor_id} + visitor_id = ytcfg.get("VISITOR_DATA") + return {"X-Goog-Visitor-Id": visitor_id} def sapisid_from_cookie(raw_cookie): cookie = SimpleCookie() - cookie.load(raw_cookie.replace("\"", "")) - return cookie['__Secure-3PAPISID'].value + cookie.load(raw_cookie.replace('"', "")) + return cookie["__Secure-3PAPISID"].value # SAPISID Hash reverse engineered by @@ -54,24 +53,22 @@ def sapisid_from_cookie(raw_cookie): def get_authorization(auth): sha_1 = sha1() unix_timestamp = str(int(time.time())) - sha_1.update((unix_timestamp + ' ' + auth).encode('utf-8')) + sha_1.update((unix_timestamp + " " + auth).encode("utf-8")) return "SAPISIDHASH " + unix_timestamp + "_" + sha_1.hexdigest() def to_int(string): string = unicodedata.normalize("NFKD", string) - number_string = re.sub(r'\D', '', string) + number_string = re.sub(r"\D", "", string) try: int_value = locale.atoi(number_string) except ValueError: - number_string = number_string.replace(',', '') + number_string = number_string.replace(",", "") int_value = int(number_string) return int_value def sum_total_duration(item): - if 'tracks' not in item: + if "tracks" not in item: return 0 - return sum([ - track['duration_seconds'] if 'duration_seconds' in track else 0 for track in item['tracks'] - ]) + return sum([track["duration_seconds"] if "duration_seconds" in track else 0 for track in item["tracks"]]) diff --git a/ytmusicapi/mixins/_utils.py b/ytmusicapi/mixins/_utils.py index a0c6d4cb..69627f04 100644 --- a/ytmusicapi/mixins/_utils.py +++ b/ytmusicapi/mixins/_utils.py @@ -3,36 +3,37 @@ def prepare_like_endpoint(rating): - if rating == 'LIKE': - return 'like/like' - elif rating == 'DISLIKE': - return 'like/dislike' - elif rating == 'INDIFFERENT': - return 'like/removelike' + if rating == "LIKE": + return "like/like" + elif rating == "DISLIKE": + return "like/dislike" + elif rating == "INDIFFERENT": + return "like/removelike" else: return None def validate_order_parameter(order): - orders = ['a_to_z', 'z_to_a', 'recently_added'] + orders = ["a_to_z", "z_to_a", "recently_added"] if order and order not in orders: raise Exception( "Invalid order provided. Please use one of the following orders or leave out the parameter: " - + ', '.join(orders)) + + ", ".join(orders) + ) def prepare_order_params(order): - orders = ['a_to_z', 'z_to_a', 'recently_added'] + orders = ["a_to_z", "z_to_a", "recently_added"] if order is not None: # determine order_params via `.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[1].itemSectionRenderer.header.itemSectionTabbedHeaderRenderer.endItems[1].dropdownRenderer.entries[].dropdownItemRenderer.onSelectCommand.browseEndpoint.params` of `/youtubei/v1/browse` response - order_params = ['ggMGKgQIARAA', 'ggMGKgQIARAB', 'ggMGKgQIABAB'] + order_params = ["ggMGKgQIARAA", "ggMGKgQIARAB", "ggMGKgQIABAB"] return order_params[orders.index(order)] def html_to_txt(html_text): tags = re.findall("<[^>]+>", html_text) for tag in tags: - html_text = html_text.replace(tag, '') + html_text = html_text.replace(tag, "") return html_text diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index c3cbdf3c..2c99c823 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -1,15 +1,16 @@ -from ._utils import get_datestamp +from typing import Dict, List + from ytmusicapi.continuations import get_continuations from ytmusicapi.helpers import YTM_DOMAIN, sum_total_duration -from ytmusicapi.parsers.browsing import * from ytmusicapi.parsers.albums import parse_album_header -from ytmusicapi.parsers.playlists import parse_playlist_items +from ytmusicapi.parsers.browsing import * from ytmusicapi.parsers.library import parse_albums -from typing import List, Dict +from ytmusicapi.parsers.playlists import parse_playlist_items +from ._utils import get_datestamp -class BrowsingMixin: +class BrowsingMixin: def get_home(self, limit=3) -> List[Dict]: """ Get the home page. @@ -96,23 +97,24 @@ def get_home(self, limit=3) -> List[Dict]: ] """ - endpoint = 'browse' + endpoint = "browse" body = {"browseId": "FEmusic_home"} response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) home = [] home.extend(parse_mixed_content(results)) - section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) - if 'continuations' in section_list: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + section_list = nav(response, SINGLE_COLUMN_TAB + ["sectionListRenderer"]) + if "continuations" in section_list: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) parse_func = lambda contents: parse_mixed_content(contents) home.extend( - get_continuations(section_list, 'sectionListContinuation', limit - len(home), - request_func, parse_func)) + get_continuations( + section_list, "sectionListContinuation", limit - len(home), request_func, parse_func + ) + ) return home @@ -210,36 +212,39 @@ def get_artist(self, channelId: str) -> Dict: """ if channelId.startswith("MPLA"): channelId = channelId[4:] - body = {'browseId': channelId} - endpoint = 'browse' + body = {"browseId": channelId} + endpoint = "browse" response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) - artist = {'description': None, 'views': None} - header = response['header']['musicImmersiveHeaderRenderer'] - artist['name'] = nav(header, TITLE_TEXT) + artist = {"description": None, "views": None} + header = response["header"]["musicImmersiveHeaderRenderer"] + artist["name"] = nav(header, TITLE_TEXT) descriptionShelf = find_object_by_key(results, DESCRIPTION_SHELF[0], is_key=True) if descriptionShelf: - artist['description'] = nav(descriptionShelf, DESCRIPTION) - artist['views'] = None if 'subheader' not in descriptionShelf else descriptionShelf[ - 'subheader']['runs'][0]['text'] - subscription_button = header['subscriptionButton']['subscribeButtonRenderer'] - artist['channelId'] = subscription_button['channelId'] - artist['shuffleId'] = nav(header, - ['playButton', 'buttonRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID, - True) - artist['radioId'] = nav(header, ['startRadioButton', 'buttonRenderer'] - + NAVIGATION_WATCH_PLAYLIST_ID, True) - artist['subscribers'] = nav(subscription_button, - ['subscriberCountText', 'runs', 0, 'text'], True) - artist['subscribed'] = subscription_button['subscribed'] - artist['thumbnails'] = nav(header, THUMBNAILS, True) - artist['songs'] = {'browseId': None} - if 'musicShelfRenderer' in results[0]: # API sometimes does not return songs + artist["description"] = nav(descriptionShelf, DESCRIPTION) + artist["views"] = ( + None + if "subheader" not in descriptionShelf + else descriptionShelf["subheader"]["runs"][0]["text"] + ) + subscription_button = header["subscriptionButton"]["subscribeButtonRenderer"] + artist["channelId"] = subscription_button["channelId"] + artist["shuffleId"] = nav( + header, ["playButton", "buttonRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID, True + ) + artist["radioId"] = nav( + header, ["startRadioButton", "buttonRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID, True + ) + artist["subscribers"] = nav(subscription_button, ["subscriberCountText", "runs", 0, "text"], True) + artist["subscribed"] = subscription_button["subscribed"] + artist["thumbnails"] = nav(header, THUMBNAILS, True) + artist["songs"] = {"browseId": None} + if "musicShelfRenderer" in results[0]: # API sometimes does not return songs musicShelf = nav(results[0], MUSIC_SHELF) - if 'navigationEndpoint' in nav(musicShelf, TITLE): - artist['songs']['browseId'] = nav(musicShelf, TITLE + NAVIGATION_BROWSE_ID) - artist['songs']['results'] = parse_playlist_items(musicShelf['contents']) + if "navigationEndpoint" in nav(musicShelf, TITLE): + artist["songs"]["browseId"] = nav(musicShelf, TITLE + NAVIGATION_BROWSE_ID) + artist["songs"]["results"] = parse_playlist_items(musicShelf["contents"]) artist.update(self.parser.parse_artist_contents(results)) return artist @@ -255,7 +260,7 @@ def get_artist_albums(self, channelId: str, params: str) -> List[Dict]: """ body = {"browseId": channelId, "params": params} - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM) results = nav(results, GRID_ITEMS, True) or nav(results, CAROUSEL_CONTENTS) @@ -311,10 +316,10 @@ def get_user(self, channelId: str) -> Dict: } } """ - endpoint = 'browse' + endpoint = "browse" body = {"browseId": channelId} response = self._send_request(endpoint, body) - user = {'name': nav(response, ['header', 'musicVisualHeaderRenderer'] + TITLE_TEXT)} + user = {"name": nav(response, ["header", "musicVisualHeaderRenderer"] + TITLE_TEXT)} results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) user.update(self.parser.parse_artist_contents(results)) return user @@ -329,8 +334,8 @@ def get_user_playlists(self, channelId: str, params: str) -> List[Dict]: :return: List of user playlists in the format of :py:func:`get_library_playlists` """ - endpoint = 'browse' - body = {"browseId": channelId, 'params': params} + endpoint = "browse" + body = {"browseId": channelId, "params": params} response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + GRID_ITEMS) user_playlists = parse_content_list(results, parse_playlist) @@ -413,19 +418,19 @@ def get_album(self, browseId: str) -> Dict: "duration_seconds": 4657 } """ - body = {'browseId': browseId} - endpoint = 'browse' + body = {"browseId": browseId} + endpoint = "browse" response = self._send_request(endpoint, body) album = parse_album_header(response) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) - album['tracks'] = parse_playlist_items(results['contents']) + album["tracks"] = parse_playlist_items(results["contents"]) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST + [1] + CAROUSEL, True) if results is not None: - album['other_versions'] = parse_content_list(results['contents'], parse_album) - album['duration_seconds'] = sum_total_duration(album) - for i, track in enumerate(album['tracks']): - album['tracks'][i]['album'] = album['title'] - album['tracks'][i]['artists'] = album['tracks'][i]['artists'] or album['artists'] + album["other_versions"] = parse_content_list(results["contents"], parse_album) + album["duration_seconds"] = sum_total_duration(album) + for i, track in enumerate(album["tracks"]): + album["tracks"][i]["album"] = album["title"] + album["tracks"][i]["artists"] = album["tracks"][i]["artists"] or album["artists"] return album @@ -600,22 +605,16 @@ def get_song(self, videoId: str, signatureTimestamp: int = None) -> Dict: } """ - endpoint = 'player' + endpoint = "player" if not signatureTimestamp: signatureTimestamp = get_datestamp() - 1 params = { - "playbackContext": { - "contentPlaybackContext": { - "signatureTimestamp": signatureTimestamp - } - }, - "video_id": videoId + "playbackContext": {"contentPlaybackContext": {"signatureTimestamp": signatureTimestamp}}, + "video_id": videoId, } response = self._send_request(endpoint, params) - keys = [ - 'videoDetails', 'playabilityStatus', 'streamingData', 'microformat', 'playbackTracking' - ] + keys = ["videoDetails", "playabilityStatus", "streamingData", "microformat", "playbackTracking"] for k in list(response.keys()): if k not in keys: del response[k] @@ -698,8 +697,8 @@ def get_song_related(self, browseId: str): if not browseId: raise Exception("Invalid browseId provided.") - response = self._send_request('browse', {'browseId': browseId}) - sections = nav(response, ['contents'] + SECTION_LIST) + response = self._send_request("browse", {"browseId": browseId}) + sections = nav(response, ["contents"] + SECTION_LIST) return parse_mixed_content(sections) def get_lyrics(self, browseId: str) -> Dict: @@ -721,12 +720,13 @@ def get_lyrics(self, browseId: str) -> Dict: if not browseId: raise Exception("Invalid browseId provided. This song might not have lyrics.") - response = self._send_request('browse', {'browseId': browseId}) - lyrics['lyrics'] = nav(response, - ['contents'] + SECTION_LIST_ITEM + DESCRIPTION_SHELF + DESCRIPTION, - True) - lyrics['source'] = nav(response, ['contents'] + SECTION_LIST_ITEM + DESCRIPTION_SHELF - + ['footer'] + RUN_TEXT, True) + response = self._send_request("browse", {"browseId": browseId}) + lyrics["lyrics"] = nav( + response, ["contents"] + SECTION_LIST_ITEM + DESCRIPTION_SHELF + DESCRIPTION, True + ) + lyrics["source"] = nav( + response, ["contents"] + SECTION_LIST_ITEM + DESCRIPTION_SHELF + ["footer"] + RUN_TEXT, True + ) return lyrics @@ -780,7 +780,7 @@ def get_tasteprofile(self) -> Dict: """ - response = self._send_request('browse', {'browseId': "FEmusic_tastebuilder"}) + response = self._send_request("browse", {"browseId": "FEmusic_tastebuilder"}) profiles = nav(response, TASTE_PROFILE_ITEMS) taste_profiles = {} @@ -789,7 +789,7 @@ def get_tasteprofile(self) -> Dict: artist = nav(item["tastebuilderItemRenderer"], TASTE_PROFILE_ARTIST)[0]["text"] taste_profiles[artist] = { "selectionValue": item["tastebuilderItemRenderer"]["selectionFormValue"], - "impressionValue": item["tastebuilderItemRenderer"]["impressionFormValue"] + "impressionValue": item["tastebuilderItemRenderer"]["impressionFormValue"], } return taste_profiles @@ -807,9 +807,8 @@ def set_tasteprofile(self, artists: List[str], taste_profile: Dict = None) -> No if taste_profile is None: taste_profile = self.get_tasteprofile() formData = { - "impressionValues": - [taste_profile[profile]["impressionValue"] for profile in taste_profile], - "selectedValues": [] + "impressionValues": [taste_profile[profile]["impressionValue"] for profile in taste_profile], + "selectedValues": [], } for artist in artists: @@ -817,5 +816,5 @@ def set_tasteprofile(self, artists: List[str], taste_profile: Dict = None) -> No raise Exception("The artist, {}, was not present in taste!".format(artist)) formData["selectedValues"].append(taste_profile[artist]["selectionValue"]) - body = {'browseId': "FEmusic_home", "formData": formData} - self._send_request('browse', body) + body = {"browseId": "FEmusic_home", "formData": formData} + self._send_request("browse", body) diff --git a/ytmusicapi/mixins/explore.py b/ytmusicapi/mixins/explore.py index 5429835b..c56266c2 100644 --- a/ytmusicapi/mixins/explore.py +++ b/ytmusicapi/mixins/explore.py @@ -1,9 +1,9 @@ +from typing import Dict, List + from ytmusicapi.parsers.explore import * -from typing import List, Dict class ExploreMixin: - def get_mood_categories(self) -> Dict: """ Fetch "Moods & Genres" categories from YouTube Music. @@ -50,15 +50,14 @@ def get_mood_categories(self) -> Dict: """ sections = {} - response = self._send_request('browse', {'browseId': 'FEmusic_moods_and_genres'}) + response = self._send_request("browse", {"browseId": "FEmusic_moods_and_genres"}) for section in nav(response, SINGLE_COLUMN_TAB + SECTION_LIST): - title = nav(section, GRID + ['header', 'gridHeaderRenderer'] + TITLE_TEXT) + title = nav(section, GRID + ["header", "gridHeaderRenderer"] + TITLE_TEXT) sections[title] = [] for category in nav(section, GRID_ITEMS): - sections[title].append({ - "title": nav(category, CATEGORY_TITLE), - "params": nav(category, CATEGORY_PARAMS) - }) + sections[title].append( + {"title": nav(category, CATEGORY_TITLE), "params": nav(category, CATEGORY_PARAMS)} + ) return sections @@ -71,25 +70,24 @@ def get_mood_playlists(self, params: str) -> List[Dict]: """ playlists = [] - response = self._send_request('browse', { - 'browseId': 'FEmusic_moods_and_genres_category', - 'params': params - }) + response = self._send_request( + "browse", {"browseId": "FEmusic_moods_and_genres_category", "params": params} + ) for section in nav(response, SINGLE_COLUMN_TAB + SECTION_LIST): path = [] - if 'gridRenderer' in section: + if "gridRenderer" in section: path = GRID_ITEMS - elif 'musicCarouselShelfRenderer' in section: + elif "musicCarouselShelfRenderer" in section: path = CAROUSEL_CONTENTS - elif 'musicImmersiveCarouselShelfRenderer' in section: - path = ['musicImmersiveCarouselShelfRenderer', 'contents'] + elif "musicImmersiveCarouselShelfRenderer" in section: + path = ["musicImmersiveCarouselShelfRenderer", "contents"] if len(path): results = nav(section, path) playlists += parse_content_list(results, parse_playlist) return playlists - def get_charts(self, country: str = 'ZZ') -> Dict: + def get_charts(self, country: str = "ZZ") -> Dict: """ Get latest charts data from YouTube Music: Top songs, top videos, top artists and top trending videos. Global charts have no Trending section, US charts have an extra Genres section with some Genre charts. @@ -189,58 +187,69 @@ def get_charts(self, country: str = 'ZZ') -> Dict: } """ - body = {'browseId': 'FEmusic_charts'} + body = {"browseId": "FEmusic_charts"} if country: - body['formData'] = {'selectedValues': [country]} + body["formData"] = {"selectedValues": [country]} - response = self._send_request('browse', body) + response = self._send_request("browse", body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) - charts = {'countries': {}} + charts = {"countries": {}} menu = nav( - results[0], MUSIC_SHELF + [ - 'subheaders', 0, 'musicSideAlignedItemRenderer', 'startItems', 0, - 'musicSortFilterButtonRenderer' - ]) - charts['countries']['selected'] = nav(menu, TITLE) - charts['countries']['options'] = list( - filter(None, [ - nav(m, ['payload', 'musicFormBooleanChoice', 'opaqueToken'], True) - for m in nav(response, FRAMEWORK_MUTATIONS) - ])) - charts_categories = ['videos', 'artists'] - - has_genres = country == 'US' - has_trending = country != 'ZZ' + results[0], + MUSIC_SHELF + + [ + "subheaders", + 0, + "musicSideAlignedItemRenderer", + "startItems", + 0, + "musicSortFilterButtonRenderer", + ], + ) + charts["countries"]["selected"] = nav(menu, TITLE) + charts["countries"]["options"] = list( + filter( + None, + [ + nav(m, ["payload", "musicFormBooleanChoice", "opaqueToken"], True) + for m in nav(response, FRAMEWORK_MUTATIONS) + ], + ) + ) + charts_categories = ["videos", "artists"] + + has_genres = country == "US" + has_trending = country != "ZZ" # use result length to determine if songs category is present # could also be done via an is_premium attribute on YTMusic instance has_songs = (len(results) - 1) > (len(charts_categories) + has_genres + has_trending) if has_songs: - charts_categories.insert(0, 'songs') + charts_categories.insert(0, "songs") if has_genres: - charts_categories.append('genres') + charts_categories.append("genres") if has_trending: - charts_categories.append('trending') + charts_categories.append("trending") parse_chart = lambda i, parse_func, key: parse_content_list( - nav(results[i + has_songs], CAROUSEL_CONTENTS), parse_func, key) + nav(results[i + has_songs], CAROUSEL_CONTENTS), parse_func, key + ) for i, c in enumerate(charts_categories): charts[c] = { - 'playlist': nav(results[1 + i], CAROUSEL + CAROUSEL_TITLE + NAVIGATION_BROWSE_ID, - True) + "playlist": nav(results[1 + i], CAROUSEL + CAROUSEL_TITLE + NAVIGATION_BROWSE_ID, True) } if has_songs: - charts['songs'].update({'items': parse_chart(0, parse_chart_song, MRLIR)}) + charts["songs"].update({"items": parse_chart(0, parse_chart_song, MRLIR)}) - charts['videos']['items'] = parse_chart(1, parse_video, MTRIR) - charts['artists']['items'] = parse_chart(2, parse_chart_artist, MRLIR) + charts["videos"]["items"] = parse_chart(1, parse_video, MTRIR) + charts["artists"]["items"] = parse_chart(2, parse_chart_artist, MRLIR) if has_genres: - charts['genres'] = parse_chart(3, parse_playlist, MTRIR) + charts["genres"] = parse_chart(3, parse_playlist, MTRIR) if has_trending: - charts['trending']['items'] = parse_chart(3 + has_genres, parse_chart_trending, MRLIR) + charts["trending"]["items"] = parse_chart(3 + has_genres, parse_chart_trending, MRLIR) return charts diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index 71b01bba..3633960e 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -1,13 +1,14 @@ from random import randint +from typing import Dict, List + from ytmusicapi.continuations import * -from ._utils import * from ytmusicapi.parsers.browsing import * from ytmusicapi.parsers.library import * -from typing import List, Dict +from ._utils import * -class LibraryMixin: +class LibraryMixin: def get_library_playlists(self, limit: int = 25) -> List[Dict]: """ Retrieves the playlists in the user's library. @@ -25,28 +26,26 @@ def get_library_playlists(self, limit: int = 25) -> List[Dict]: } """ self._check_auth() - body = {'browseId': 'FEmusic_liked_playlists'} - endpoint = 'browse' + body = {"browseId": "FEmusic_liked_playlists"} + endpoint = "browse" response = self._send_request(endpoint, body) results = get_library_contents(response, GRID) - playlists = parse_content_list(results['items'][1:], parse_playlist) + playlists = parse_content_list(results["items"][1:], parse_playlist) - if 'continuations' in results: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + if "continuations" in results: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) parse_func = lambda contents: parse_content_list(contents, parse_playlist) remaining_limit = None if limit is None else (limit - len(playlists)) playlists.extend( - get_continuations(results, 'gridContinuation', remaining_limit, request_func, - parse_func)) + get_continuations(results, "gridContinuation", remaining_limit, request_func, parse_func) + ) return playlists - def get_library_songs(self, - limit: int = 25, - validate_responses: bool = False, - order: str = None) -> List[Dict]: + def get_library_songs( + self, limit: int = 25, validate_responses: bool = False, order: str = None + ) -> List[Dict]: """ Gets the songs in the user's library (liked videos are not included). To get liked songs and videos, use :py:func:`get_liked_songs` @@ -58,11 +57,11 @@ def get_library_songs(self, :return: List of songs. Same format as :py:func:`get_playlist` """ self._check_auth() - body = {'browseId': 'FEmusic_liked_videos'} + body = {"browseId": "FEmusic_liked_videos"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" per_page = 25 request_func = lambda additionalParams: self._send_request(endpoint, body) @@ -73,32 +72,45 @@ def get_library_songs(self, if validate_responses: validate_func = lambda parsed: validate_response(parsed, per_page, limit, 0) - response = resend_request_until_parsed_response_is_valid(request_func, None, - parse_func, validate_func, 3) + response = resend_request_until_parsed_response_is_valid( + request_func, None, parse_func, validate_func, 3 + ) else: response = parse_func(request_func(None)) - results = response['results'] - songs = response['parsed'] + results = response["results"] + songs = response["parsed"] if songs is None: return [] - if 'continuations' in results: + if "continuations" in results: request_continuations_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + endpoint, body, additionalParams + ) parse_continuations_func = lambda contents: parse_playlist_items(contents) if validate_responses: songs.extend( - get_validated_continuations(results, 'musicShelfContinuation', - limit - len(songs), per_page, - request_continuations_func, - parse_continuations_func)) + get_validated_continuations( + results, + "musicShelfContinuation", + limit - len(songs), + per_page, + request_continuations_func, + parse_continuations_func, + ) + ) else: remaining_limit = None if limit is None else (limit - len(songs)) songs.extend( - get_continuations(results, 'musicShelfContinuation', remaining_limit, - request_continuations_func, parse_continuations_func)) + get_continuations( + results, + "musicShelfContinuation", + remaining_limit, + request_continuations_func, + parse_continuations_func, + ) + ) return songs @@ -126,16 +138,16 @@ def get_library_albums(self, limit: int = 25, order: str = None) -> List[Dict]: } """ self._check_auth() - body = {'browseId': 'FEmusic_liked_albums'} + body = {"browseId": "FEmusic_liked_albums"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_albums( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_library_artists(self, limit: int = 25, order: str = None) -> List[Dict]: """ @@ -155,15 +167,15 @@ def get_library_artists(self, limit: int = 25, order: str = None) -> List[Dict]: } """ self._check_auth() - body = {'browseId': 'FEmusic_library_corpus_track_artists'} + body = {"browseId": "FEmusic_library_corpus_track_artists"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_artists( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_library_subscriptions(self, limit: int = 25, order: str = None) -> List[Dict]: """ @@ -174,15 +186,15 @@ def get_library_subscriptions(self, limit: int = 25, order: str = None) -> List[ :return: List of artists. Same format as :py:func:`get_library_artists` """ self._check_auth() - body = {'browseId': 'FEmusic_library_corpus_artists'} + body = {"browseId": "FEmusic_library_corpus_artists"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_artists( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_liked_songs(self, limit: int = 100) -> Dict: """ @@ -191,7 +203,7 @@ def get_liked_songs(self, limit: int = 100) -> Dict: :param limit: How many items to return. Default: 100 :return: List of playlistItem dictionaries. See :py:func:`get_playlist` """ - return self.get_playlist('LM', limit) + return self.get_playlist("LM", limit) def get_history(self) -> List[Dict]: """ @@ -202,20 +214,20 @@ def get_history(self) -> List[Dict]: The additional property ``feedbackToken`` can be used to remove items with :py:func:`remove_history_items` """ self._check_auth() - body = {'browseId': 'FEmusic_history'} - endpoint = 'browse' + body = {"browseId": "FEmusic_history"} + endpoint = "browse" response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) songs = [] for content in results: - data = nav(content, MUSIC_SHELF + ['contents'], True) + data = nav(content, MUSIC_SHELF + ["contents"], True) if not data: - error = nav(content, ['musicNotifierShelfRenderer'] + TITLE, True) + error = nav(content, ["musicNotifierShelfRenderer"] + TITLE, True) raise Exception(error) menu_entries = [[-1] + MENU_SERVICE + FEEDBACK_TOKEN] songlist = parse_playlist_items(data, menu_entries) for song in songlist: - song['played'] = nav(content['musicShelfRenderer'], TITLE_TEXT) + song["played"] = nav(content["musicShelfRenderer"], TITLE_TEXT) songs.extend(songlist) return songs @@ -229,7 +241,7 @@ def add_history_item(self, song): :return: Full response. response.status_code is 204 if successful """ url = song["playbackTracking"]["videostatsPlaybackUrl"]["baseUrl"] - CPNA = ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_") + CPNA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" cpn = "".join((CPNA[randint(0, 256) & 63] for _ in range(0, 16))) params = {"ver": 2, "c": "WEB_REMIX", "cpn": cpn} return self._send_get_request(url, params) @@ -242,13 +254,13 @@ def remove_history_items(self, feedbackTokens: List[str]) -> Dict: # pragma: no :return: Full response """ self._check_auth() - body = {'feedbackTokens': feedbackTokens} - endpoint = 'feedback' + body = {"feedbackTokens": feedbackTokens} + endpoint = "feedback" response = self._send_request(endpoint, body) return response - def rate_song(self, videoId: str, rating: str = 'INDIFFERENT') -> Dict: + def rate_song(self, videoId: str, rating: str = "INDIFFERENT") -> Dict: """ Rates a song ("thumbs up"/"thumbs down" interactions on YouTube Music) @@ -260,7 +272,7 @@ def rate_song(self, videoId: str, rating: str = 'INDIFFERENT') -> Dict: :return: Full response """ self._check_auth() - body = {'target': {'videoId': videoId}} + body = {"target": {"videoId": videoId}} endpoint = prepare_like_endpoint(rating) if endpoint is None: return @@ -276,11 +288,11 @@ def edit_song_library_status(self, feedbackTokens: List[str] = None) -> Dict: :return: Full response """ self._check_auth() - body = {'feedbackTokens': feedbackTokens} - endpoint = 'feedback' + body = {"feedbackTokens": feedbackTokens} + endpoint = "feedback" return endpoint if not endpoint else self._send_request(endpoint, body) - def rate_playlist(self, playlistId: str, rating: str = 'INDIFFERENT') -> Dict: + def rate_playlist(self, playlistId: str, rating: str = "INDIFFERENT") -> Dict: """ Rates a playlist/album ("Add to library"/"Remove from library" interactions on YouTube Music) You can also dislike a playlist/album, which has an effect on your recommendations @@ -293,7 +305,7 @@ def rate_playlist(self, playlistId: str, rating: str = 'INDIFFERENT') -> Dict: :return: Full response """ self._check_auth() - body = {'target': {'playlistId': playlistId}} + body = {"target": {"playlistId": playlistId}} endpoint = prepare_like_endpoint(rating) return endpoint if not endpoint else self._send_request(endpoint, body) @@ -305,8 +317,8 @@ def subscribe_artists(self, channelIds: List[str]) -> Dict: :return: Full response """ self._check_auth() - body = {'channelIds': channelIds} - endpoint = 'subscription/subscribe' + body = {"channelIds": channelIds} + endpoint = "subscription/subscribe" return self._send_request(endpoint, body) def unsubscribe_artists(self, channelIds: List[str]) -> Dict: @@ -317,6 +329,6 @@ def unsubscribe_artists(self, channelIds: List[str]) -> Dict: :return: Full response """ self._check_auth() - body = {'channelIds': channelIds} - endpoint = 'subscription/unsubscribe' + body = {"channelIds": channelIds} + endpoint = "subscription/unsubscribe" return self._send_request(endpoint, body) diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index 997918c9..a7bcc0c4 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -1,20 +1,18 @@ -from typing import Dict, Union, Tuple, Optional -from ._utils import * +from typing import Dict, Optional, Tuple, Union from ytmusicapi.continuations import * +from ytmusicapi.helpers import sum_total_duration, to_int from ytmusicapi.navigation import * -from ytmusicapi.helpers import to_int, sum_total_duration -from ytmusicapi.parsers.playlists import * from ytmusicapi.parsers.browsing import parse_content_list, parse_playlist +from ytmusicapi.parsers.playlists import * +from ._utils import * -class PlaylistsMixin: - def get_playlist(self, - playlistId: str, - limit: int = 100, - related: bool = False, - suggestions_limit: int = 0) -> Dict: +class PlaylistsMixin: + def get_playlist( + self, playlistId: str, limit: int = 100, related: bool = False, suggestions_limit: int = 0 + ) -> Dict: """ Returns a list of playlist items @@ -104,57 +102,55 @@ def get_playlist(self, needed for moving/removing playlist items """ browseId = "VL" + playlistId if not playlistId.startswith("VL") else playlistId - body = {'browseId': browseId} - endpoint = 'browse' + body = {"browseId": browseId} + endpoint = "browse" 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'] + 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' + header = response["header"]["musicDetailHeaderRenderer"] + playlist["privacy"] = "PUBLIC" else: - header = response['header']['musicEditablePlaylistDetailHeaderRenderer'] - playlist['privacy'] = header['editHeader']['musicPlaylistEditHeaderRenderer'][ - 'privacy'] - header = header['header']['musicDetailHeaderRenderer'] + header = response["header"]["musicEditablePlaylistDetailHeaderRenderer"] + playlist["privacy"] = header["editHeader"]["musicPlaylistEditHeaderRenderer"]["privacy"] + header = header["header"]["musicDetailHeaderRenderer"] - playlist['title'] = nav(header, TITLE_TEXT) - playlist['thumbnails'] = nav(header, THUMBNAIL_CROPPED) + 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) + 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["year"] = nav(header, SUBTITLE3) - playlist['views'] = None - playlist['duration'] = None - if 'runs' in header['secondSubtitle']: - second_subtitle_runs = header['secondSubtitle']['runs'] + 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']) + 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(" ") + 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']) + song_count = len(results["contents"]) - playlist['trackCount'] = song_count + playlist["trackCount"] = song_count - request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams - ) + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) # suggestions and related are missing e.g. on liked songs - section_list = nav(response, SINGLE_COLUMN_TAB + ['sectionListRenderer']) - playlist['related'] = [] - if 'continuations' in section_list: + section_list = nav(response, SINGLE_COLUMN_TAB + ["sectionListRenderer"]) + playlist["related"] = [] + if "continuations" in section_list: additionalParams = get_continuation_params(section_list) if own_playlist and (suggestions_limit > 0 or related): parse_func = lambda results: parse_playlist_items(results) @@ -162,44 +158,52 @@ def get_playlist(self, 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) + 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)) + 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["related"] = get_continuation_contents( + nav(continuation, CONTENT + CAROUSEL), parse_func + ) - playlist['tracks'] = [] - if 'contents' in results: - playlist['tracks'] = parse_playlist_items(results['contents']) + playlist["tracks"] = [] + if "contents" in results: + playlist["tracks"] = parse_playlist_items(results["contents"]) parse_func = lambda contents: parse_playlist_items(contents) - if 'continuations' in results: - playlist['tracks'].extend( - get_continuations(results, 'musicPlaylistShelfContinuation', limit, - request_func, parse_func)) - - playlist['duration_seconds'] = sum_total_duration(playlist) + if "continuations" in results: + playlist["tracks"].extend( + get_continuations( + results, "musicPlaylistShelfContinuation", limit, request_func, parse_func + ) + ) + + playlist["duration_seconds"] = sum_total_duration(playlist) return playlist - def create_playlist(self, - title: str, - description: str, - privacy_status: str = "PRIVATE", - video_ids: List = None, - source_playlist: str = None) -> Union[str, Dict]: + def create_playlist( + self, + title: str, + description: str, + privacy_status: str = "PRIVATE", + video_ids: List = None, + source_playlist: str = None, + ) -> Union[str, Dict]: """ Creates a new empty playlist and returns its id. @@ -212,28 +216,30 @@ def create_playlist(self, """ self._check_auth() body = { - 'title': title, - 'description': html_to_txt(description), # YT does not allow HTML tags - 'privacyStatus': privacy_status + "title": title, + "description": html_to_txt(description), # YT does not allow HTML tags + "privacyStatus": privacy_status, } if video_ids is not None: - body['videoIds'] = video_ids + body["videoIds"] = video_ids if source_playlist is not None: - body['sourcePlaylistId'] = source_playlist + body["sourcePlaylistId"] = source_playlist - endpoint = 'playlist/create' + endpoint = "playlist/create" response = self._send_request(endpoint, body) - return response['playlistId'] if 'playlistId' in response else response - - def edit_playlist(self, - playlistId: str, - title: str = None, - description: str = None, - privacyStatus: str = None, - moveItem: Tuple[str, str] = None, - addPlaylistId: str = None, - addToTop: Optional[bool] = None) -> Union[str, Dict]: + return response["playlistId"] if "playlistId" in response else response + + def edit_playlist( + self, + playlistId: str, + title: str = None, + description: str = None, + privacyStatus: str = None, + moveItem: Tuple[str, str] = None, + addPlaylistId: str = None, + addToTop: Optional[bool] = None, + ) -> Union[str, Dict]: """ Edit title, description or privacyStatus of a playlist. You may also move an item within a playlist or append another playlist to this playlist. @@ -249,43 +255,39 @@ def edit_playlist(self, :return: Status String or full response """ self._check_auth() - body = {'playlistId': validate_playlist_id(playlistId)} + body = {"playlistId": validate_playlist_id(playlistId)} actions = [] if title: - actions.append({'action': 'ACTION_SET_PLAYLIST_NAME', 'playlistName': title}) + actions.append({"action": "ACTION_SET_PLAYLIST_NAME", "playlistName": title}) if description: - actions.append({ - 'action': 'ACTION_SET_PLAYLIST_DESCRIPTION', - 'playlistDescription': description - }) + actions.append({"action": "ACTION_SET_PLAYLIST_DESCRIPTION", "playlistDescription": description}) if privacyStatus: - actions.append({ - 'action': 'ACTION_SET_PLAYLIST_PRIVACY', - 'playlistPrivacy': privacyStatus - }) + actions.append({"action": "ACTION_SET_PLAYLIST_PRIVACY", "playlistPrivacy": privacyStatus}) if moveItem: - actions.append({ - 'action': 'ACTION_MOVE_VIDEO_BEFORE', - 'setVideoId': moveItem[0], - 'movedSetVideoIdSuccessor': moveItem[1] - }) + actions.append( + { + "action": "ACTION_MOVE_VIDEO_BEFORE", + "setVideoId": moveItem[0], + "movedSetVideoIdSuccessor": moveItem[1], + } + ) if addPlaylistId: - actions.append({'action': 'ACTION_ADD_PLAYLIST', 'addedFullListId': addPlaylistId}) + actions.append({"action": "ACTION_ADD_PLAYLIST", "addedFullListId": addPlaylistId}) if addToTop: - actions.append({'action': 'ACTION_SET_ADD_TO_TOP', 'addToTop': 'true'}) + actions.append({"action": "ACTION_SET_ADD_TO_TOP", "addToTop": "true"}) if addToTop is not None: - actions.append({'action': 'ACTION_SET_ADD_TO_TOP', 'addToTop': str(addToTop)}) + actions.append({"action": "ACTION_SET_ADD_TO_TOP", "addToTop": str(addToTop)}) - body['actions'] = actions - endpoint = 'browse/edit_playlist' + body["actions"] = actions + endpoint = "browse/edit_playlist" response = self._send_request(endpoint, body) - return response['status'] if 'status' in response else response + return response["status"] if "status" in response else response def delete_playlist(self, playlistId: str) -> Union[str, Dict]: """ @@ -295,16 +297,18 @@ def delete_playlist(self, playlistId: str) -> Union[str, Dict]: :return: Status String or full response """ self._check_auth() - body = {'playlistId': validate_playlist_id(playlistId)} - endpoint = 'playlist/delete' + body = {"playlistId": validate_playlist_id(playlistId)} + endpoint = "playlist/delete" response = self._send_request(endpoint, body) - return response['status'] if 'status' in response else response - - def add_playlist_items(self, - playlistId: str, - videoIds: List[str] = None, - source_playlist: str = None, - duplicates: bool = False) -> Union[str, Dict]: + return response["status"] if "status" in response else response + + def add_playlist_items( + self, + playlistId: str, + videoIds: List[str] = None, + source_playlist: str = None, + duplicates: bool = False, + ) -> Union[str, Dict]: """ Add songs to an existing playlist @@ -315,32 +319,28 @@ def add_playlist_items(self, :return: Status String and a dict containing the new setVideoId for each videoId or full response """ self._check_auth() - body = {'playlistId': validate_playlist_id(playlistId), 'actions': []} + body = {"playlistId": validate_playlist_id(playlistId), "actions": []} if not videoIds and not source_playlist: - raise Exception( - "You must provide either videoIds or a source_playlist to add to the playlist") + raise Exception("You must provide either videoIds or a source_playlist to add to the playlist") if videoIds: for videoId in videoIds: - action = {'action': 'ACTION_ADD_VIDEO', 'addedVideoId': videoId} + action = {"action": "ACTION_ADD_VIDEO", "addedVideoId": videoId} if duplicates: - action['dedupeOption'] = 'DEDUPE_OPTION_SKIP' - body['actions'].append(action) + action["dedupeOption"] = "DEDUPE_OPTION_SKIP" + body["actions"].append(action) if source_playlist: - body['actions'].append({ - 'action': 'ACTION_ADD_PLAYLIST', - 'addedFullListId': source_playlist - }) + body["actions"].append({"action": "ACTION_ADD_PLAYLIST", "addedFullListId": source_playlist}) # add an empty ACTION_ADD_VIDEO because otherwise # YTM doesn't return the dict that maps videoIds to their new setVideoIds if not videoIds: - body['actions'].append({'action': 'ACTION_ADD_VIDEO', 'addedVideoId': None}) + body["actions"].append({"action": "ACTION_ADD_VIDEO", "addedVideoId": None}) - endpoint = 'browse/edit_playlist' + endpoint = "browse/edit_playlist" response = self._send_request(endpoint, body) - if 'status' in response and 'SUCCEEDED' in response['status']: + if "status" in response and "SUCCEEDED" in response["status"]: result_dict = [ result_data.get("playlistEditVideoAddedResultData") for result_data in response.get("playlistEditResults", []) @@ -359,19 +359,20 @@ def remove_playlist_items(self, playlistId: str, videos: List[Dict]) -> Union[st :return: Status String or full response """ self._check_auth() - videos = list(filter(lambda x: 'videoId' in x and 'setVideoId' in x, videos)) + videos = list(filter(lambda x: "videoId" in x and "setVideoId" in x, videos)) if len(videos) == 0: - raise Exception( - "Cannot remove songs, because setVideoId is missing. Do you own this playlist?") + raise Exception("Cannot remove songs, because setVideoId is missing. Do you own this playlist?") - body = {'playlistId': validate_playlist_id(playlistId), 'actions': []} + body = {"playlistId": validate_playlist_id(playlistId), "actions": []} for video in videos: - body['actions'].append({ - 'setVideoId': video['setVideoId'], - 'removedVideoId': video['videoId'], - 'action': 'ACTION_REMOVE_VIDEO' - }) + body["actions"].append( + { + "setVideoId": video["setVideoId"], + "removedVideoId": video["videoId"], + "action": "ACTION_REMOVE_VIDEO", + } + ) - endpoint = 'browse/edit_playlist' + endpoint = "browse/edit_playlist" response = self._send_request(endpoint, body) - return response['status'] if 'status' in response else response + return response["status"] if "status" in response else response diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 0eeac063..783a48b3 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -1,16 +1,18 @@ -from typing import List, Dict, Union +from typing import Dict, List, Union + from ytmusicapi.continuations import get_continuations from ytmusicapi.parsers.search import * class SearchMixin: - - def search(self, - query: str, - filter: str = None, - scope: str = None, - limit: int = 20, - ignore_spelling: bool = False) -> List[Dict]: + def search( + self, + query: str, + filter: str = None, + scope: str = None, + limit: int = 20, + ignore_spelling: bool = False, + ) -> List[Dict]: """ Search YouTube music Returns results within the provided category. @@ -130,79 +132,94 @@ def search(self, """ - body = {'query': query} - endpoint = 'search' + body = {"query": query} + endpoint = "search" search_results = [] filters = [ - 'albums', 'artists', 'playlists', 'community_playlists', 'featured_playlists', 'songs', - 'videos', 'profiles', 'podcasts', 'episodes' + "albums", + "artists", + "playlists", + "community_playlists", + "featured_playlists", + "songs", + "videos", + "profiles", + "podcasts", + "episodes", ] if filter and filter not in filters: raise Exception( "Invalid filter provided. Please use one of the following filters or leave out the parameter: " - + ', '.join(filters)) + + ", ".join(filters) + ) - scopes = ['library', 'uploads'] + scopes = ["library", "uploads"] if scope and scope not in scopes: raise Exception( "Invalid scope provided. Please use one of the following scopes or leave out the parameter: " - + ', '.join(scopes)) + + ", ".join(scopes) + ) if scope == scopes[1] and filter: raise Exception( "No filter can be set when searching uploads. Please unset the filter parameter when scope is set to " - "uploads. ") + "uploads. " + ) if scope == scopes[0] and filter in filters[3:5]: - raise Exception(f"{filter} cannot be set when searching library. " - f"Please use one of the following filters or leave out the parameter: " - + ', '.join(filters[0:3] + filters[5:])) + raise Exception( + f"{filter} cannot be set when searching library. " + f"Please use one of the following filters or leave out the parameter: " + + ", ".join(filters[0:3] + filters[5:]) + ) params = get_search_params(filter, scope, ignore_spelling) if params: - body['params'] = params + body["params"] = params response = self._send_request(endpoint, body) # no results - if 'contents' not in response: + if "contents" not in response: return search_results - if 'tabbedSearchResultsRenderer' in response['contents']: + if "tabbedSearchResultsRenderer" in response["contents"]: tab_index = 0 if not scope or filter else scopes.index(scope) + 1 - results = response['contents']['tabbedSearchResultsRenderer']['tabs'][tab_index][ - 'tabRenderer']['content'] + results = response["contents"]["tabbedSearchResultsRenderer"]["tabs"][tab_index]["tabRenderer"][ + "content" + ] else: - results = response['contents'] + results = response["contents"] results = nav(results, SECTION_LIST) # no results - if len(results) == 1 and 'itemSectionRenderer' in results: + if len(results) == 1 and "itemSectionRenderer" in results: return search_results # set filter for parser - if filter and 'playlists' in filter: - filter = 'playlists' + if filter and "playlists" in filter: + filter = "playlists" elif scope == scopes[1]: filter = scopes[1] for res in results: - if 'musicCardShelfRenderer' in res: - top_result = parse_top_result(res['musicCardShelfRenderer'], - self.parser.get_search_result_types()) + if "musicCardShelfRenderer" in res: + top_result = parse_top_result( + res["musicCardShelfRenderer"], self.parser.get_search_result_types() + ) search_results.append(top_result) - if results := nav(res, ['musicCardShelfRenderer', 'contents'], True): + if results := nav(res, ["musicCardShelfRenderer", "contents"], True): category = None # category "more from youtube" is missing sometimes - if 'messageRenderer' in results[0]: - category = nav(results.pop(0), ['messageRenderer'] + TEXT_RUN_TEXT) + if "messageRenderer" in results[0]: + category = nav(results.pop(0), ["messageRenderer"] + TEXT_RUN_TEXT) type = None else: continue - elif 'musicShelfRenderer' in res: - results = res['musicShelfRenderer']['contents'] + elif "musicShelfRenderer" in res: + results = res["musicShelfRenderer"]["contents"] type_filter = filter category = nav(res, MUSIC_SHELF + TITLE_TEXT, True) if not type_filter and scope == scopes[0]: @@ -214,8 +231,7 @@ def search(self, continue search_result_types = self.parser.get_search_result_types() - search_results.extend( - parse_search_results(results, search_result_types, type, category)) + search_results.extend(parse_search_results(results, search_result_types, type, category)) if filter: # if filter is set, there are continuations @@ -226,14 +242,18 @@ def parse_func(contents): return parse_search_results(contents, search_result_types, type, category) search_results.extend( - get_continuations(res['musicShelfRenderer'], 'musicShelfContinuation', - limit - len(search_results), request_func, parse_func)) + get_continuations( + res["musicShelfRenderer"], + "musicShelfContinuation", + limit - len(search_results), + request_func, + parse_func, + ) + ) return search_results - def get_search_suggestions(self, - query: str, - detailed_runs=False) -> Union[List[str], List[Dict]]: + def get_search_suggestions(self, query: str, detailed_runs=False) -> Union[List[str], List[Dict]]: """ Get Search Suggestions @@ -300,8 +320,8 @@ def get_search_suggestions(self, ] """ - body = {'input': query} - endpoint = 'music/get_search_suggestions' + body = {"input": query} + endpoint = "music/get_search_suggestions" response = self._send_request(endpoint, body) search_suggestions = parse_search_suggestions(response, detailed_runs) diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index feab698b..1f87842d 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -1,21 +1,26 @@ -import requests import ntpath import os -from typing import List, Dict, Union +from typing import Dict, List, Union + +import requests -from ._utils import validate_order_parameter, prepare_order_params +from ytmusicapi.continuations import get_continuations from ytmusicapi.helpers import * from ytmusicapi.navigation import * -from ytmusicapi.continuations import get_continuations -from ytmusicapi.parsers.library import parse_library_albums, parse_library_artists, get_library_contents, \ - pop_songs_random_mix from ytmusicapi.parsers.albums import parse_album_header +from ytmusicapi.parsers.library import ( + get_library_contents, + parse_library_albums, + parse_library_artists, + pop_songs_random_mix, +) from ytmusicapi.parsers.uploads import parse_uploaded_items + from ..auth.types import AuthType +from ._utils import prepare_order_params, validate_order_parameter class UploadsMixin: - def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[Dict]: """ Returns a list of uploaded songs @@ -40,7 +45,7 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D } """ self._check_auth() - endpoint = 'browse' + endpoint = "browse" body = {"browseId": "FEmusic_library_privately_owned_tracks"} validate_order_parameter(order) if order is not None: @@ -50,15 +55,16 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D if results is None: return [] pop_songs_random_mix(results) - songs = parse_uploaded_items(results['contents']) + songs = parse_uploaded_items(results["contents"]) - if 'continuations' in results: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + if "continuations" in results: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) remaining_limit = None if limit is None else (limit - len(songs)) songs.extend( - get_continuations(results, 'musicShelfContinuation', remaining_limit, request_func, - parse_uploaded_items)) + get_continuations( + results, "musicShelfContinuation", remaining_limit, request_func, parse_uploaded_items + ) + ) return songs @@ -71,15 +77,15 @@ def get_library_upload_albums(self, limit: int = 25, order: str = None) -> List[ :return: List of albums as returned by :py:func:`get_library_albums` """ self._check_auth() - body = {'browseId': 'FEmusic_library_privately_owned_releases'} + body = {"browseId": "FEmusic_library_privately_owned_releases"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_albums( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_library_upload_artists(self, limit: int = 25, order: str = None) -> List[Dict]: """ @@ -90,15 +96,15 @@ def get_library_upload_artists(self, limit: int = 25, order: str = None) -> List :return: List of artists as returned by :py:func:`get_library_artists` """ self._check_auth() - body = {'browseId': 'FEmusic_library_privately_owned_artists'} + body = {"browseId": "FEmusic_library_privately_owned_artists"} validate_order_parameter(order) if order is not None: body["params"] = prepare_order_params(order) - endpoint = 'browse' + endpoint = "browse" response = self._send_request(endpoint, body) return parse_library_artists( - response, - lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit) + response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit + ) def get_library_upload_artist(self, browseId: str, limit: int = 25) -> List[Dict]: """ @@ -128,23 +134,24 @@ def get_library_upload_artist(self, browseId: str, limit: int = 25) -> List[Dict ] """ self._check_auth() - body = {'browseId': browseId} - endpoint = 'browse' + body = {"browseId": browseId} + endpoint = "browse" response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) - if len(results['contents']) > 1: - results['contents'].pop(0) + if len(results["contents"]) > 1: + results["contents"].pop(0) - items = parse_uploaded_items(results['contents']) + items = parse_uploaded_items(results["contents"]) - if 'continuations' in results: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + if "continuations" in results: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) parse_func = lambda contents: parse_uploaded_items(contents) remaining_limit = None if limit is None else (limit - len(items)) items.extend( - get_continuations(results, 'musicShelfContinuation', remaining_limit, request_func, - parse_func)) + get_continuations( + results, "musicShelfContinuation", remaining_limit, request_func, parse_func + ) + ) return items @@ -181,13 +188,13 @@ def get_library_upload_album(self, browseId: str) -> Dict: }, """ self._check_auth() - body = {'browseId': browseId} - endpoint = 'browse' + body = {"browseId": browseId} + endpoint = "browse" response = self._send_request(endpoint, body) album = parse_album_header(response) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + MUSIC_SHELF) - album['tracks'] = parse_uploaded_items(results['contents']) - album['duration_seconds'] = sum_total_duration(album) + album["tracks"] = parse_uploaded_items(results["contents"]) + album["duration_seconds"] = sum_total_duration(album) return album def upload_song(self, filepath: str) -> Union[str, requests.Response]: @@ -207,27 +214,29 @@ def upload_song(self, filepath: str) -> Union[str, requests.Response]: if os.path.splitext(filepath)[1][1:] not in supported_filetypes: raise Exception( "The provided file type is not supported by YouTube Music. Supported file types are " - + ', '.join(supported_filetypes)) + + ", ".join(supported_filetypes) + ) headers = self.headers.copy() - upload_url = "https://upload.youtube.com/upload/usermusic/http?authuser=%s" % headers[ - 'x-goog-authuser'] + upload_url = ( + "https://upload.youtube.com/upload/usermusic/http?authuser=%s" % headers["x-goog-authuser"] + ) filesize = os.path.getsize(filepath) - body = ("filename=" + ntpath.basename(filepath)).encode('utf-8') - headers.pop('content-encoding', None) - headers['content-type'] = 'application/x-www-form-urlencoded;charset=utf-8' - headers['X-Goog-Upload-Command'] = 'start' - headers['X-Goog-Upload-Header-Content-Length'] = str(filesize) - headers['X-Goog-Upload-Protocol'] = 'resumable' + body = ("filename=" + ntpath.basename(filepath)).encode("utf-8") + headers.pop("content-encoding", None) + headers["content-type"] = "application/x-www-form-urlencoded;charset=utf-8" + headers["X-Goog-Upload-Command"] = "start" + headers["X-Goog-Upload-Header-Content-Length"] = str(filesize) + headers["X-Goog-Upload-Protocol"] = "resumable" response = requests.post(upload_url, data=body, headers=headers, proxies=self.proxies) - headers['X-Goog-Upload-Command'] = 'upload, finalize' - headers['X-Goog-Upload-Offset'] = '0' - upload_url = response.headers['X-Goog-Upload-URL'] - with open(filepath, 'rb') as file: + headers["X-Goog-Upload-Command"] = "upload, finalize" + headers["X-Goog-Upload-Offset"] = "0" + upload_url = response.headers["X-Goog-Upload-URL"] + with open(filepath, "rb") as file: response = requests.post(upload_url, data=file, headers=headers, proxies=self.proxies) if response.status_code == 200: - return 'STATUS_SUCCEEDED' + return "STATUS_SUCCEEDED" else: return response @@ -240,14 +249,14 @@ def delete_upload_entity(self, entityId: str) -> Union[str, Dict]: # pragma: no :return: Status String or error """ self._check_auth() - endpoint = 'music/delete_privately_owned_entity' - if 'FEmusic_library_privately_owned_release_detail' in entityId: - entityId = entityId.replace('FEmusic_library_privately_owned_release_detail', '') + endpoint = "music/delete_privately_owned_entity" + if "FEmusic_library_privately_owned_release_detail" in entityId: + entityId = entityId.replace("FEmusic_library_privately_owned_release_detail", "") body = {"entityId": entityId} response = self._send_request(endpoint, body) - if 'error' not in response: - return 'STATUS_SUCCEEDED' + if "error" not in response: + return "STATUS_SUCCEEDED" else: - return response['error'] + return response["error"] diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index 412c89dd..4e5fc37a 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -1,4 +1,4 @@ -from typing import List, Dict, Union +from typing import Dict, List, Union from ytmusicapi.continuations import get_continuations from ytmusicapi.parsers.playlists import validate_playlist_id @@ -6,12 +6,14 @@ class WatchMixin: - def get_watch_playlist(self, - videoId: str = None, - playlistId: str = None, - limit=25, - radio: bool = False, - shuffle: bool = False) -> Dict[str, Union[List[Dict]]]: + def get_watch_playlist( + self, + videoId: str = None, + playlistId: str = None, + limit=25, + radio: bool = False, + shuffle: bool = False, + ) -> Dict[str, Union[List[Dict]]]: """ Get a watch list of tracks. This watch playlist appears when you press play on a track in YouTube Music. @@ -101,59 +103,71 @@ def get_watch_playlist(self, """ body = { - 'enablePersistentPlaylistPanel': True, - 'isAudioOnly': True, - 'tunerSettingValue': 'AUTOMIX_SETTING_NORMAL' + "enablePersistentPlaylistPanel": True, + "isAudioOnly": True, + "tunerSettingValue": "AUTOMIX_SETTING_NORMAL", } if not videoId and not playlistId: raise Exception("You must provide either a video id, a playlist id, or both") if videoId: - body['videoId'] = videoId + body["videoId"] = videoId if not playlistId: playlistId = "RDAMVM" + videoId if not (radio or shuffle): - body['watchEndpointMusicSupportedConfigs'] = { - 'watchEndpointMusicConfig': { - 'hasPersistentPlaylistPanel': True, - 'musicVideoType': "MUSIC_VIDEO_TYPE_ATV", + body["watchEndpointMusicSupportedConfigs"] = { + "watchEndpointMusicConfig": { + "hasPersistentPlaylistPanel": True, + "musicVideoType": "MUSIC_VIDEO_TYPE_ATV", } } - body['playlistId'] = validate_playlist_id(playlistId) - is_playlist = body['playlistId'].startswith('PL') or \ - body['playlistId'].startswith('OLA') + body["playlistId"] = validate_playlist_id(playlistId) + is_playlist = body["playlistId"].startswith("PL") or body["playlistId"].startswith("OLA") if shuffle and playlistId is not None: - body['params'] = "wAEB8gECKAE%3D" + body["params"] = "wAEB8gECKAE%3D" if radio: - body['params'] = "wAEB" - endpoint = 'next' + body["params"] = "wAEB" + endpoint = "next" response = self._send_request(endpoint, body) - watchNextRenderer = nav(response, [ - 'contents', 'singleColumnMusicWatchNextResultsRenderer', 'tabbedRenderer', - 'watchNextTabbedResultsRenderer' - ]) + watchNextRenderer = nav( + response, + [ + "contents", + "singleColumnMusicWatchNextResultsRenderer", + "tabbedRenderer", + "watchNextTabbedResultsRenderer", + ], + ) lyrics_browse_id = get_tab_browse_id(watchNextRenderer, 1) related_browse_id = get_tab_browse_id(watchNextRenderer, 2) - results = nav(watchNextRenderer, - TAB_CONTENT + ['musicQueueRenderer', 'content', 'playlistPanelRenderer']) + results = nav( + watchNextRenderer, TAB_CONTENT + ["musicQueueRenderer", "content", "playlistPanelRenderer"] + ) playlist = next( filter( bool, map( - lambda x: nav(x, ['playlistPanelVideoRenderer'] + NAVIGATION_PLAYLIST_ID, True - ), results['contents'])), None) - tracks = parse_watch_playlist(results['contents']) + lambda x: nav(x, ["playlistPanelVideoRenderer"] + NAVIGATION_PLAYLIST_ID, True), + results["contents"], + ), + ), + None, + ) + tracks = parse_watch_playlist(results["contents"]) - if 'continuations' in results: - request_func = lambda additionalParams: self._send_request( - endpoint, body, additionalParams) + if "continuations" in results: + request_func = lambda additionalParams: self._send_request(endpoint, body, additionalParams) parse_func = lambda contents: parse_watch_playlist(contents) tracks.extend( - get_continuations(results, 'playlistPanelContinuation', limit - len(tracks), - request_func, parse_func, '' if is_playlist else 'Radio')) + get_continuations( + results, + "playlistPanelContinuation", + limit - len(tracks), + request_func, + parse_func, + "" if is_playlist else "Radio", + ) + ) - return dict(tracks=tracks, - playlistId=playlist, - lyrics=lyrics_browse_id, - related=related_browse_id) + return dict(tracks=tracks, playlistId=playlist, lyrics=lyrics_browse_id, related=related_browse_id) diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 3357f4d8..6b2bfd8c 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -1,72 +1,70 @@ # commonly used navigation paths -CONTENT = ['contents', 0] -RUN_TEXT = ['runs', 0, 'text'] -TAB_CONTENT = ['tabs', 0, 'tabRenderer', 'content'] -TAB_1_CONTENT = ['tabs', 1, 'tabRenderer', 'content'] -SINGLE_COLUMN = ['contents', 'singleColumnBrowseResultsRenderer'] +CONTENT = ["contents", 0] +RUN_TEXT = ["runs", 0, "text"] +TAB_CONTENT = ["tabs", 0, "tabRenderer", "content"] +TAB_1_CONTENT = ["tabs", 1, "tabRenderer", "content"] +SINGLE_COLUMN = ["contents", "singleColumnBrowseResultsRenderer"] SINGLE_COLUMN_TAB = SINGLE_COLUMN + TAB_CONTENT -SECTION_LIST = ['sectionListRenderer', 'contents'] -SECTION_LIST_ITEM = ['sectionListRenderer'] + CONTENT -ITEM_SECTION = ['itemSectionRenderer'] + CONTENT -MUSIC_SHELF = ['musicShelfRenderer'] -GRID = ['gridRenderer'] -GRID_ITEMS = GRID + ['items'] -MENU = ['menu', 'menuRenderer'] -MENU_ITEMS = MENU + ['items'] -MENU_LIKE_STATUS = MENU + ['topLevelButtons', 0, 'likeButtonRenderer', 'likeStatus'] -MENU_SERVICE = ['menuServiceItemRenderer', 'serviceEndpoint'] -TOGGLE_MENU = 'toggleMenuServiceItemRenderer' -PLAY_BUTTON = [ - 'overlay', 'musicItemThumbnailOverlayRenderer', 'content', 'musicPlayButtonRenderer' -] -NAVIGATION_BROWSE = ['navigationEndpoint', 'browseEndpoint'] -NAVIGATION_BROWSE_ID = NAVIGATION_BROWSE + ['browseId'] -PAGE_TYPE = [ - 'browseEndpointContextSupportedConfigs', 'browseEndpointContextMusicConfig', 'pageType' -] -WATCH_VIDEO_ID = ['watchEndpoint', 'videoId'] -NAVIGATION_VIDEO_ID = ['navigationEndpoint'] + WATCH_VIDEO_ID -QUEUE_VIDEO_ID = ['queueAddEndpoint','queueTarget','videoId'] -NAVIGATION_PLAYLIST_ID = ['navigationEndpoint', 'watchEndpoint', 'playlistId'] -NAVIGATION_WATCH_PLAYLIST_ID = ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'] +SECTION_LIST = ["sectionListRenderer", "contents"] +SECTION_LIST_ITEM = ["sectionListRenderer"] + CONTENT +ITEM_SECTION = ["itemSectionRenderer"] + CONTENT +MUSIC_SHELF = ["musicShelfRenderer"] +GRID = ["gridRenderer"] +GRID_ITEMS = GRID + ["items"] +MENU = ["menu", "menuRenderer"] +MENU_ITEMS = MENU + ["items"] +MENU_LIKE_STATUS = MENU + ["topLevelButtons", 0, "likeButtonRenderer", "likeStatus"] +MENU_SERVICE = ["menuServiceItemRenderer", "serviceEndpoint"] +TOGGLE_MENU = "toggleMenuServiceItemRenderer" +PLAY_BUTTON = ["overlay", "musicItemThumbnailOverlayRenderer", "content", "musicPlayButtonRenderer"] +NAVIGATION_BROWSE = ["navigationEndpoint", "browseEndpoint"] +NAVIGATION_BROWSE_ID = NAVIGATION_BROWSE + ["browseId"] +PAGE_TYPE = ["browseEndpointContextSupportedConfigs", "browseEndpointContextMusicConfig", "pageType"] +WATCH_VIDEO_ID = ["watchEndpoint", "videoId"] +NAVIGATION_VIDEO_ID = ["navigationEndpoint"] + WATCH_VIDEO_ID +QUEUE_VIDEO_ID = ["queueAddEndpoint", "queueTarget", "videoId"] +NAVIGATION_PLAYLIST_ID = ["navigationEndpoint", "watchEndpoint", "playlistId"] +NAVIGATION_WATCH_PLAYLIST_ID = ["navigationEndpoint", "watchPlaylistEndpoint", "playlistId"] NAVIGATION_VIDEO_TYPE = [ - 'watchEndpoint', 'watchEndpointMusicSupportedConfigs', 'watchEndpointMusicConfig', - 'musicVideoType' + "watchEndpoint", + "watchEndpointMusicSupportedConfigs", + "watchEndpointMusicConfig", + "musicVideoType", ] -TITLE = ['title', 'runs', 0] -TITLE_TEXT = ['title'] + RUN_TEXT -TEXT_RUNS = ['text', 'runs'] +TITLE = ["title", "runs", 0] +TITLE_TEXT = ["title"] + RUN_TEXT +TEXT_RUNS = ["text", "runs"] TEXT_RUN = TEXT_RUNS + [0] -TEXT_RUN_TEXT = TEXT_RUN + ['text'] -SUBTITLE = ['subtitle'] + RUN_TEXT -SUBTITLE_RUNS = ['subtitle', 'runs'] -SUBTITLE2 = SUBTITLE_RUNS + [2, 'text'] -SUBTITLE3 = SUBTITLE_RUNS + [4, 'text'] -THUMBNAIL = ['thumbnail', 'thumbnails'] -THUMBNAILS = ['thumbnail', 'musicThumbnailRenderer'] + THUMBNAIL -THUMBNAIL_RENDERER = ['thumbnailRenderer', 'musicThumbnailRenderer'] + THUMBNAIL -THUMBNAIL_CROPPED = ['thumbnail', 'croppedSquareThumbnailRenderer'] + THUMBNAIL -FEEDBACK_TOKEN = ['feedbackEndpoint', 'feedbackToken'] -BADGE_PATH = [0, 'musicInlineBadgeRenderer', 'accessibilityData', 'accessibilityData', 'label'] -BADGE_LABEL = ['badges'] + BADGE_PATH -SUBTITLE_BADGE_LABEL = ['subtitleBadges'] + BADGE_PATH -CATEGORY_TITLE = ['musicNavigationButtonRenderer', 'buttonText'] + RUN_TEXT -CATEGORY_PARAMS = ['musicNavigationButtonRenderer', 'clickCommand', 'browseEndpoint', 'params'] -MRLIR = 'musicResponsiveListItemRenderer' -MTRIR = 'musicTwoRowItemRenderer' +TEXT_RUN_TEXT = TEXT_RUN + ["text"] +SUBTITLE = ["subtitle"] + RUN_TEXT +SUBTITLE_RUNS = ["subtitle", "runs"] +SUBTITLE2 = SUBTITLE_RUNS + [2, "text"] +SUBTITLE3 = SUBTITLE_RUNS + [4, "text"] +THUMBNAIL = ["thumbnail", "thumbnails"] +THUMBNAILS = ["thumbnail", "musicThumbnailRenderer"] + THUMBNAIL +THUMBNAIL_RENDERER = ["thumbnailRenderer", "musicThumbnailRenderer"] + THUMBNAIL +THUMBNAIL_CROPPED = ["thumbnail", "croppedSquareThumbnailRenderer"] + THUMBNAIL +FEEDBACK_TOKEN = ["feedbackEndpoint", "feedbackToken"] +BADGE_PATH = [0, "musicInlineBadgeRenderer", "accessibilityData", "accessibilityData", "label"] +BADGE_LABEL = ["badges"] + BADGE_PATH +SUBTITLE_BADGE_LABEL = ["subtitleBadges"] + BADGE_PATH +CATEGORY_TITLE = ["musicNavigationButtonRenderer", "buttonText"] + RUN_TEXT +CATEGORY_PARAMS = ["musicNavigationButtonRenderer", "clickCommand", "browseEndpoint", "params"] +MRLIR = "musicResponsiveListItemRenderer" +MTRIR = "musicTwoRowItemRenderer" TASTE_PROFILE_ITEMS = ["contents", "tastebuilderRenderer", "contents"] TASTE_PROFILE_ARTIST = ["title", "runs"] -SECTION_LIST_CONTINUATION = ['continuationContents', 'sectionListContinuation'] -MENU_PLAYLIST_ID = MENU_ITEMS + [0, 'menuNavigationItemRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID -HEADER_DETAIL = ['header', 'musicDetailHeaderRenderer'] -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 -FRAMEWORK_MUTATIONS = ['frameworkUpdates', 'entityBatchUpdate', 'mutations'] +SECTION_LIST_CONTINUATION = ["continuationContents", "sectionListContinuation"] +MENU_PLAYLIST_ID = MENU_ITEMS + [0, "menuNavigationItemRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID +HEADER_DETAIL = ["header", "musicDetailHeaderRenderer"] +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 +FRAMEWORK_MUTATIONS = ["frameworkUpdates", "entityBatchUpdate", "mutations"] def nav(root, items, none_if_absent=False): diff --git a/ytmusicapi/parsers/_utils.py b/ytmusicapi/parsers/_utils.py index 1200cd50..ccb2efff 100644 --- a/ytmusicapi/parsers/_utils.py +++ b/ytmusicapi/parsers/_utils.py @@ -4,19 +4,19 @@ def parse_menu_playlists(data, result): - watch_menu = find_objects_by_key(nav(data, MENU_ITEMS), 'menuNavigationItemRenderer') - for item in [_x['menuNavigationItemRenderer'] for _x in watch_menu]: - icon = nav(item, ['icon', 'iconType']) - if icon == 'MUSIC_SHUFFLE': - watch_key = 'shuffleId' - elif icon == 'MIX': - watch_key = 'radioId' + watch_menu = find_objects_by_key(nav(data, MENU_ITEMS), "menuNavigationItemRenderer") + for item in [_x["menuNavigationItemRenderer"] for _x in watch_menu]: + icon = nav(item, ["icon", "iconType"]) + if icon == "MUSIC_SHUFFLE": + watch_key = "shuffleId" + elif icon == "MIX": + watch_key = "radioId" else: continue - watch_id = nav(item, ['navigationEndpoint', 'watchPlaylistEndpoint', 'playlistId'], True) + watch_id = nav(item, ["navigationEndpoint", "watchPlaylistEndpoint", "playlistId"], True) if not watch_id: - watch_id = nav(item, ['navigationEndpoint', 'watchEndpoint', 'playlistId'], True) + watch_id = nav(item, ["navigationEndpoint", "watchEndpoint", "playlistId"], True) if watch_id: result[watch_key] = watch_id @@ -25,39 +25,43 @@ def get_item_text(item, index, run_index=0, none_if_absent=False): column = get_flex_column_item(item, index) if not column: return None - if none_if_absent and len(column['text']['runs']) < run_index + 1: + if none_if_absent and len(column["text"]["runs"]) < run_index + 1: return None - return column['text']['runs'][run_index]['text'] + return column["text"]["runs"][run_index]["text"] def get_flex_column_item(item, index): - if len(item['flexColumns']) <= index or \ - 'text' not in item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer'] or \ - 'runs' not in item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer']['text']: + if ( + len(item["flexColumns"]) <= index + or "text" not in item["flexColumns"][index]["musicResponsiveListItemFlexColumnRenderer"] + or "runs" not in item["flexColumns"][index]["musicResponsiveListItemFlexColumnRenderer"]["text"] + ): return None - return item['flexColumns'][index]['musicResponsiveListItemFlexColumnRenderer'] + return item["flexColumns"][index]["musicResponsiveListItemFlexColumnRenderer"] def get_fixed_column_item(item, index): - if 'text' not in item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer'] or \ - 'runs' not in item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer']['text']: + if ( + "text" not in item["fixedColumns"][index]["musicResponsiveListItemFixedColumnRenderer"] + or "runs" not in item["fixedColumns"][index]["musicResponsiveListItemFixedColumnRenderer"]["text"] + ): return None - return item['fixedColumns'][index]['musicResponsiveListItemFixedColumnRenderer'] + return item["fixedColumns"][index]["musicResponsiveListItemFixedColumnRenderer"] def get_browse_id(item, index): - if 'navigationEndpoint' not in item['text']['runs'][index]: + if "navigationEndpoint" not in item["text"]["runs"][index]: return None else: - return nav(item['text']['runs'][index], NAVIGATION_BROWSE_ID) + return nav(item["text"]["runs"][index], NAVIGATION_BROWSE_ID) def get_dot_separator_index(runs): index = len(runs) try: - index = runs.index({'text': ' • '}) + index = runs.index({"text": " • "}) except ValueError: len(runs) return index @@ -74,7 +78,7 @@ def parse_duration(duration): def i18n(method): @wraps(method) def _impl(self, *method_args, **method_kwargs): - method.__globals__['_'] = self.lang.gettext + method.__globals__["_"] = self.lang.gettext return method(self, *method_args, **method_kwargs) return _impl diff --git a/ytmusicapi/parsers/albums.py b/ytmusicapi/parsers/albums.py index 3c9201d3..2d7e6852 100644 --- a/ytmusicapi/parsers/albums.py +++ b/ytmusicapi/parsers/albums.py @@ -1,37 +1,36 @@ -from ._utils import * from ytmusicapi.helpers import to_int -from .songs import parse_song_runs, parse_like_status + +from ._utils import * +from .songs import parse_like_status, parse_song_runs def parse_album_header(response): header = nav(response, HEADER_DETAIL) album = { - 'title': nav(header, TITLE_TEXT), - 'type': nav(header, SUBTITLE), - 'thumbnails': nav(header, THUMBNAIL_CROPPED) + "title": nav(header, TITLE_TEXT), + "type": nav(header, SUBTITLE), + "thumbnails": nav(header, THUMBNAIL_CROPPED), } if "description" in header: album["description"] = header["description"]["runs"][0]["text"] - album_info = parse_song_runs(header['subtitle']['runs'][2:]) + album_info = parse_song_runs(header["subtitle"]["runs"][2:]) album.update(album_info) - if len(header['secondSubtitle']['runs']) > 1: - album['trackCount'] = to_int(header['secondSubtitle']['runs'][0]['text']) - album['duration'] = header['secondSubtitle']['runs'][2]['text'] + if len(header["secondSubtitle"]["runs"]) > 1: + album["trackCount"] = to_int(header["secondSubtitle"]["runs"][0]["text"]) + album["duration"] = header["secondSubtitle"]["runs"][2]["text"] else: - album['duration'] = header['secondSubtitle']['runs'][0]['text'] + album["duration"] = header["secondSubtitle"]["runs"][0]["text"] # add to library/uploaded menu = nav(header, MENU) - toplevel = menu['topLevelButtons'] - album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_WATCH_PLAYLIST_ID, - True) - if not album['audioPlaylistId']: - album['audioPlaylistId'] = nav(toplevel, [0, 'buttonRenderer'] + NAVIGATION_PLAYLIST_ID, - True) - service = nav(toplevel, [1, 'buttonRenderer', 'defaultServiceEndpoint'], True) + toplevel = menu["topLevelButtons"] + album["audioPlaylistId"] = nav(toplevel, [0, "buttonRenderer"] + NAVIGATION_WATCH_PLAYLIST_ID, True) + if not album["audioPlaylistId"]: + album["audioPlaylistId"] = nav(toplevel, [0, "buttonRenderer"] + NAVIGATION_PLAYLIST_ID, True) + service = nav(toplevel, [1, "buttonRenderer", "defaultServiceEndpoint"], True) if service: - album['likeStatus'] = parse_like_status(service) + album["likeStatus"] = parse_like_status(service) return album diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 5fefab0b..2348395d 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -1,5 +1,5 @@ -from .songs import * from ._utils import * +from .songs import * def parse_mixed_content(rows): @@ -7,15 +7,15 @@ def parse_mixed_content(rows): for row in rows: if DESCRIPTION_SHELF[0] in row: results = nav(row, DESCRIPTION_SHELF) - title = nav(results, ['header'] + RUN_TEXT) + title = nav(results, ["header"] + RUN_TEXT) contents = nav(results, DESCRIPTION) else: results = next(iter(row.values())) - if 'contents' not in results: + if "contents" not in results: continue - title = nav(results, CAROUSEL_TITLE + ['text']) + title = nav(results, CAROUSEL_TITLE + ["text"]) contents = [] - for result in results['contents']: + for result in results["contents"]: data = nav(result, [MTRIR], True) content = None if data: @@ -37,7 +37,7 @@ def parse_mixed_content(rows): contents.append(content) - items.append({'title': title, 'contents': contents}) + items.append({"title": title, "contents": contents}) return items @@ -51,51 +51,50 @@ def parse_content_list(results, parse_func, key=MTRIR): def parse_album(result): return { - 'title': nav(result, TITLE_TEXT), - 'year': nav(result, SUBTITLE2, True), - 'browseId': nav(result, TITLE + NAVIGATION_BROWSE_ID), - 'thumbnails': nav(result, THUMBNAIL_RENDERER), - 'isExplicit': nav(result, SUBTITLE_BADGE_LABEL, True) is not None + "title": nav(result, TITLE_TEXT), + "year": nav(result, SUBTITLE2, True), + "browseId": nav(result, TITLE + NAVIGATION_BROWSE_ID), + "thumbnails": nav(result, THUMBNAIL_RENDERER), + "isExplicit": nav(result, SUBTITLE_BADGE_LABEL, True) is not None, } def parse_single(result): return { - 'title': nav(result, TITLE_TEXT), - 'year': nav(result, SUBTITLE, True), - 'browseId': nav(result, TITLE + NAVIGATION_BROWSE_ID), - 'thumbnails': nav(result, THUMBNAIL_RENDERER) + "title": nav(result, TITLE_TEXT), + "year": nav(result, SUBTITLE, True), + "browseId": nav(result, TITLE + NAVIGATION_BROWSE_ID), + "thumbnails": nav(result, THUMBNAIL_RENDERER), } def parse_song(result): song = { - 'title': nav(result, TITLE_TEXT), - 'videoId': nav(result, NAVIGATION_VIDEO_ID), - 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), - 'thumbnails': nav(result, THUMBNAIL_RENDERER) + "title": nav(result, TITLE_TEXT), + "videoId": nav(result, NAVIGATION_VIDEO_ID), + "playlistId": nav(result, NAVIGATION_PLAYLIST_ID, True), + "thumbnails": nav(result, THUMBNAIL_RENDERER), } song.update(parse_song_runs(nav(result, SUBTITLE_RUNS))) return song def parse_song_flat(data): - columns = [get_flex_column_item(data, i) for i in range(0, len(data['flexColumns']))] + columns = [get_flex_column_item(data, i) for i in range(0, len(data["flexColumns"]))] song = { - 'title': nav(columns[0], TEXT_RUN_TEXT), - 'videoId': nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID, True), - 'artists': parse_song_artists(data, 1), - 'thumbnails': nav(data, THUMBNAILS), - 'isExplicit': nav(data, BADGE_LABEL, True) is not None + "title": nav(columns[0], TEXT_RUN_TEXT), + "videoId": nav(columns[0], TEXT_RUN + NAVIGATION_VIDEO_ID, True), + "artists": parse_song_artists(data, 1), + "thumbnails": nav(data, THUMBNAILS), + "isExplicit": nav(data, BADGE_LABEL, True) is not None, } - if len(columns) > 2 and columns[2] is not None and 'navigationEndpoint' in nav( - columns[2], TEXT_RUN): - song['album'] = { - 'name': nav(columns[2], TEXT_RUN_TEXT), - 'id': nav(columns[2], TEXT_RUN + NAVIGATION_BROWSE_ID) + if len(columns) > 2 and columns[2] is not None and "navigationEndpoint" in nav(columns[2], TEXT_RUN): + song["album"] = { + "name": nav(columns[2], TEXT_RUN_TEXT), + "id": nav(columns[2], TEXT_RUN + NAVIGATION_BROWSE_ID), } else: - song['views'] = nav(columns[1], ['text', 'runs', -1, 'text']).split(' ')[0] + song["views"] = nav(columns[1], ["text", "runs", -1, "text"]).split(" ")[0] return song @@ -105,30 +104,31 @@ def parse_video(result): artists_len = get_dot_separator_index(runs) videoId = nav(result, NAVIGATION_VIDEO_ID, True) if not videoId: - videoId = next(id for entry in nav(result, MENU_ITEMS) - if nav(entry, MENU_SERVICE + QUEUE_VIDEO_ID, True)) + videoId = next( + id for entry in nav(result, MENU_ITEMS) if nav(entry, MENU_SERVICE + QUEUE_VIDEO_ID, True) + ) return { - 'title': nav(result, TITLE_TEXT), - 'videoId': videoId, - 'artists': parse_song_artists_runs(runs[:artists_len]), - 'playlistId': nav(result, NAVIGATION_PLAYLIST_ID, True), - 'thumbnails': nav(result, THUMBNAIL_RENDERER, True), - 'views': runs[-1]['text'].split(' ')[0] + "title": nav(result, TITLE_TEXT), + "videoId": videoId, + "artists": parse_song_artists_runs(runs[:artists_len]), + "playlistId": nav(result, NAVIGATION_PLAYLIST_ID, True), + "thumbnails": nav(result, THUMBNAIL_RENDERER, True), + "views": runs[-1]["text"].split(" ")[0], } def parse_playlist(data): playlist = { - 'title': nav(data, TITLE_TEXT), - 'playlistId': nav(data, TITLE + NAVIGATION_BROWSE_ID)[2:], - 'thumbnails': nav(data, THUMBNAIL_RENDERER) + "title": nav(data, TITLE_TEXT), + "playlistId": nav(data, TITLE + NAVIGATION_BROWSE_ID)[2:], + "thumbnails": nav(data, THUMBNAIL_RENDERER), } - subtitle = data['subtitle'] - if 'runs' in subtitle: - playlist['description'] = "".join([run['text'] for run in subtitle['runs']]) - if len(subtitle['runs']) == 3 and re.search(r'\d+ ', nav(data, SUBTITLE2)): - playlist['count'] = nav(data, SUBTITLE2).split(' ')[0] - playlist['author'] = parse_song_artists_runs(subtitle['runs'][:1]) + subtitle = data["subtitle"] + if "runs" in subtitle: + playlist["description"] = "".join([run["text"] for run in subtitle["runs"]]) + if len(subtitle["runs"]) == 3 and re.search(r"\d+ ", nav(data, SUBTITLE2)): + playlist["count"] = nav(data, SUBTITLE2).split(" ")[0] + playlist["author"] = parse_song_artists_runs(subtitle["runs"][:1]) return playlist @@ -136,18 +136,18 @@ def parse_playlist(data): def parse_related_artist(data): subscribers = nav(data, SUBTITLE, True) if subscribers: - subscribers = subscribers.split(' ')[0] + subscribers = subscribers.split(" ")[0] return { - 'title': nav(data, TITLE_TEXT), - 'browseId': nav(data, TITLE + NAVIGATION_BROWSE_ID), - 'subscribers': subscribers, - 'thumbnails': nav(data, THUMBNAIL_RENDERER), + "title": nav(data, TITLE_TEXT), + "browseId": nav(data, TITLE + NAVIGATION_BROWSE_ID), + "subscribers": subscribers, + "thumbnails": nav(data, THUMBNAIL_RENDERER), } def parse_watch_playlist(data): return { - 'title': nav(data, TITLE_TEXT), - 'playlistId': nav(data, NAVIGATION_WATCH_PLAYLIST_ID), - 'thumbnails': nav(data, THUMBNAIL_RENDERER), + "title": nav(data, TITLE_TEXT), + "playlistId": nav(data, NAVIGATION_WATCH_PLAYLIST_ID), + "thumbnails": nav(data, THUMBNAIL_RENDERER), } diff --git a/ytmusicapi/parsers/explore.py b/ytmusicapi/parsers/explore.py index 6f1a371d..510b5a7a 100644 --- a/ytmusicapi/parsers/explore.py +++ b/ytmusicapi/parsers/explore.py @@ -1,6 +1,6 @@ from ytmusicapi.parsers.browsing import * -TRENDS = {'ARROW_DROP_UP': 'up', 'ARROW_DROP_DOWN': 'down', 'ARROW_CHART_NEUTRAL': 'neutral'} +TRENDS = {"ARROW_DROP_UP": "up", "ARROW_DROP_DOWN": "down", "ARROW_CHART_NEUTRAL": "neutral"} def parse_chart_song(data): @@ -12,13 +12,13 @@ def parse_chart_song(data): def parse_chart_artist(data): subscribers = get_flex_column_item(data, 1) if subscribers: - subscribers = nav(subscribers, TEXT_RUN_TEXT).split(' ')[0] + subscribers = nav(subscribers, TEXT_RUN_TEXT).split(" ")[0] parsed = { - 'title': nav(get_flex_column_item(data, 0), TEXT_RUN_TEXT), - 'browseId': nav(data, NAVIGATION_BROWSE_ID), - 'subscribers': subscribers, - 'thumbnails': nav(data, THUMBNAILS), + "title": nav(get_flex_column_item(data, 0), TEXT_RUN_TEXT), + "browseId": nav(data, NAVIGATION_BROWSE_ID), + "subscribers": subscribers, + "thumbnails": nav(data, THUMBNAILS), } parsed.update(parse_ranking(data)) return parsed @@ -29,22 +29,21 @@ def parse_chart_trending(data): artists = parse_song_artists(data, 1) index = get_dot_separator_index(artists) # last item is views for some reason - views = None if index == len(artists) else artists.pop()['name'].split(' ')[0] + views = None if index == len(artists) else artists.pop()["name"].split(" ")[0] return { - 'title': nav(flex_0, TEXT_RUN_TEXT), - 'videoId': nav(flex_0, TEXT_RUN + NAVIGATION_VIDEO_ID, True), - 'playlistId': nav(flex_0, TEXT_RUN + NAVIGATION_PLAYLIST_ID, True), - 'artists': artists, - 'thumbnails': nav(data, THUMBNAILS), - 'views': views + "title": nav(flex_0, TEXT_RUN_TEXT), + "videoId": nav(flex_0, TEXT_RUN + NAVIGATION_VIDEO_ID, True), + "playlistId": nav(flex_0, TEXT_RUN + NAVIGATION_PLAYLIST_ID, True), + "artists": artists, + "thumbnails": nav(data, THUMBNAILS), + "views": views, } def parse_ranking(data): return { - 'rank': - nav(data, ['customIndexColumn', 'musicCustomIndexColumnRenderer'] + TEXT_RUN_TEXT), - 'trend': - TRENDS[nav(data, - ['customIndexColumn', 'musicCustomIndexColumnRenderer', 'icon', 'iconType'])] + "rank": nav(data, ["customIndexColumn", "musicCustomIndexColumnRenderer"] + TEXT_RUN_TEXT), + "trend": TRENDS[ + nav(data, ["customIndexColumn", "musicCustomIndexColumnRenderer", "icon", "iconType"]) + ], } diff --git a/ytmusicapi/parsers/i18n.py b/ytmusicapi/parsers/i18n.py index d6cf3d22..289a4571 100644 --- a/ytmusicapi/parsers/i18n.py +++ b/ytmusicapi/parsers/i18n.py @@ -1,45 +1,56 @@ -from typing import List, Dict +from typing import Dict, List -from ytmusicapi.navigation import nav, CAROUSEL, CAROUSEL_TITLE, NAVIGATION_BROWSE_ID +from ytmusicapi.navigation import CAROUSEL, CAROUSEL_TITLE, NAVIGATION_BROWSE_ID, nav from ytmusicapi.parsers._utils import i18n -from ytmusicapi.parsers.browsing import parse_album, parse_single, parse_video, parse_playlist, parse_related_artist, \ - parse_content_list +from ytmusicapi.parsers.browsing import ( + parse_album, + parse_content_list, + parse_playlist, + parse_related_artist, + parse_single, + parse_video, +) class Parser: - def __init__(self, language): self.lang = language @i18n def get_search_result_types(self): - return [_('artist'), _('playlist'), _('song'), _('video'), _('station'), _('profile'), _('podcast'), _('episode')] + return [ + _("artist"), + _("playlist"), + _("song"), + _("video"), + _("station"), + _("profile"), + _("podcast"), + _("episode"), + ] @i18n def parse_artist_contents(self, results: List) -> Dict: - categories = ['albums', 'singles', 'videos', 'playlists', 'related'] - categories_local = [_('albums'), _('singles'), _('videos'), _('playlists'), _('related')] - categories_parser = [ - parse_album, parse_single, parse_video, parse_playlist, parse_related_artist - ] + categories = ["albums", "singles", "videos", "playlists", "related"] + categories_local = [_("albums"), _("singles"), _("videos"), _("playlists"), _("related")] + categories_parser = [parse_album, parse_single, parse_video, parse_playlist, parse_related_artist] artist = {} for i, category in enumerate(categories): data = [ - r['musicCarouselShelfRenderer'] for r in results - if 'musicCarouselShelfRenderer' in r - and nav(r, CAROUSEL + CAROUSEL_TITLE)['text'].lower() == categories_local[i] + r["musicCarouselShelfRenderer"] + for r in results + if "musicCarouselShelfRenderer" in r + and nav(r, CAROUSEL + CAROUSEL_TITLE)["text"].lower() == categories_local[i] ] if len(data) > 0: - artist[category] = {'browseId': None, 'results': []} - if 'navigationEndpoint' in nav(data[0], CAROUSEL_TITLE): - artist[category]['browseId'] = nav(data[0], - CAROUSEL_TITLE + NAVIGATION_BROWSE_ID) - if category in ['albums', 'singles', 'playlists']: - artist[category]['params'] = nav( - data[0], - CAROUSEL_TITLE)['navigationEndpoint']['browseEndpoint']['params'] - - artist[category]['results'] = parse_content_list(data[0]['contents'], - categories_parser[i]) + artist[category] = {"browseId": None, "results": []} + if "navigationEndpoint" in nav(data[0], CAROUSEL_TITLE): + artist[category]["browseId"] = nav(data[0], CAROUSEL_TITLE + NAVIGATION_BROWSE_ID) + if category in ["albums", "singles", "playlists"]: + artist[category]["params"] = nav(data[0], CAROUSEL_TITLE)["navigationEndpoint"][ + "browseEndpoint" + ]["params"] + + artist[category]["results"] = parse_content_list(data[0]["contents"], categories_parser[i]) return artist diff --git a/ytmusicapi/parsers/library.py b/ytmusicapi/parsers/library.py index 1c6e07d6..7cc64640 100644 --- a/ytmusicapi/parsers/library.py +++ b/ytmusicapi/parsers/library.py @@ -1,7 +1,8 @@ +from ytmusicapi.continuations import get_continuations + +from ._utils import * from .playlists import parse_playlist_items from .songs import parse_song_runs -from ._utils import * -from ytmusicapi.continuations import get_continuations def parse_artists(results, uploaded=False): @@ -9,16 +10,16 @@ def parse_artists(results, uploaded=False): for result in results: data = result[MRLIR] artist = {} - artist['browseId'] = nav(data, NAVIGATION_BROWSE_ID) - artist['artist'] = get_item_text(data, 0) + artist["browseId"] = nav(data, NAVIGATION_BROWSE_ID) + artist["artist"] = get_item_text(data, 0) parse_menu_playlists(data, artist) if uploaded: - artist['songs'] = get_item_text(data, 1).split(' ')[0] + artist["songs"] = get_item_text(data, 1).split(" ")[0] else: subtitle = get_item_text(data, 1) if subtitle: - artist['subscribers'] = subtitle.split(' ')[0] - artist['thumbnails'] = nav(data, THUMBNAILS, True) + artist["subscribers"] = subtitle.split(" ")[0] + artist["thumbnails"] = nav(data, THUMBNAILS, True) artists.append(artist) return artists @@ -28,14 +29,14 @@ def parse_library_albums(response, request_func, limit): results = get_library_contents(response, GRID) if results is None: return [] - albums = parse_albums(results['items']) + albums = parse_albums(results["items"]) - if 'continuations' in results: + if "continuations" in results: parse_func = lambda contents: parse_albums(contents) remaining_limit = None if limit is None else (limit - len(albums)) albums.extend( - get_continuations(results, 'gridContinuation', remaining_limit, request_func, - parse_func)) + get_continuations(results, "gridContinuation", remaining_limit, request_func, parse_func) + ) return albums @@ -45,14 +46,14 @@ def parse_albums(results): for result in results: data = result[MTRIR] album = {} - album['browseId'] = nav(data, TITLE + NAVIGATION_BROWSE_ID) - album['playlistId'] = nav(data, MENU_PLAYLIST_ID, none_if_absent=True) - album['title'] = nav(data, TITLE_TEXT) - album['thumbnails'] = nav(data, THUMBNAIL_RENDERER) + album["browseId"] = nav(data, TITLE + NAVIGATION_BROWSE_ID) + album["playlistId"] = nav(data, MENU_PLAYLIST_ID, none_if_absent=True) + album["title"] = nav(data, TITLE_TEXT) + album["thumbnails"] = nav(data, THUMBNAIL_RENDERER) - if 'runs' in data['subtitle']: - album['type'] = nav(data, SUBTITLE) - album.update(parse_song_runs(data['subtitle']['runs'][2:])) + if "runs" in data["subtitle"]: + album["type"] = nav(data, SUBTITLE) + album.update(parse_song_runs(data["subtitle"]["runs"][2:])) albums.append(album) @@ -63,14 +64,14 @@ def parse_library_artists(response, request_func, limit): results = get_library_contents(response, MUSIC_SHELF) if results is None: return [] - artists = parse_artists(results['contents']) + artists = parse_artists(results["contents"]) - if 'continuations' in results: + if "continuations" in results: parse_func = lambda contents: parse_artists(contents) remaining_limit = None if limit is None else (limit - len(artists)) artists.extend( - get_continuations(results, 'musicShelfContinuation', remaining_limit, request_func, - parse_func)) + get_continuations(results, "musicShelfContinuation", remaining_limit, request_func, parse_func) + ) return artists @@ -78,17 +79,14 @@ def parse_library_artists(response, request_func, limit): def pop_songs_random_mix(results) -> None: """remove the random mix that conditionally appears at the start of library songs""" if results: - if len(results['contents']) >= 2: - results['contents'].pop(0) + if len(results["contents"]) >= 2: + results["contents"].pop(0) def parse_library_songs(response): results = get_library_contents(response, MUSIC_SHELF) pop_songs_random_mix(results) - return { - 'results': results, - 'parsed': parse_playlist_items(results['contents']) if results else results - } + return {"results": results, "parsed": parse_playlist_items(results["contents"]) if results else results} def get_library_contents(response, renderer): @@ -103,10 +101,9 @@ def get_library_contents(response, renderer): section = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST, True) contents = None if section is None: # empty library - contents = nav(response, SINGLE_COLUMN + TAB_1_CONTENT + SECTION_LIST_ITEM + renderer, - True) + contents = nav(response, SINGLE_COLUMN + TAB_1_CONTENT + SECTION_LIST_ITEM + renderer, True) else: - results = find_object_by_key(section, 'itemSectionRenderer') + results = find_object_by_key(section, "itemSectionRenderer") if results is None: contents = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + renderer, True) else: diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index 655f2660..a626f963 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -1,6 +1,7 @@ from typing import List -from .songs import * + from ._utils import * +from .songs import * def parse_playlist_items(results, menu_entries: List[List] = None): @@ -16,13 +17,17 @@ def parse_playlist_items(results, menu_entries: List[List] = None): library_status = None # if the item has a menu, find its setVideoId - if 'menu' in data: + if "menu" in data: for item in nav(data, MENU_ITEMS): - if 'menuServiceItemRenderer' in item: + 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 "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) @@ -30,15 +35,14 @@ def parse_playlist_items(results, menu_entries: List[List] = None): # 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 "playNavigationEndpoint" in nav(data, PLAY_BUTTON): + videoId = nav(data, PLAY_BUTTON)["playNavigationEndpoint"]["watchEndpoint"]["videoId"] - if 'menu' in data: + if "menu" in data: like = nav(data, MENU_LIKE_STATUS, True) title = get_item_text(data, 0) - if title == 'Song deleted': + if title == "Song deleted": continue artists = parse_song_artists(data, 1) @@ -46,46 +50,49 @@ def parse_playlist_items(results, menu_entries: List[List] = None): album = parse_song_album(data, 2) 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'] + 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'] + duration = get_fixed_column_item(data, 0)["text"]["runs"][0]["text"] thumbnails = None - if 'thumbnail' in data: + if "thumbnail" in data: thumbnails = nav(data, THUMBNAILS) isAvailable = True - if 'musicItemRendererDisplayPolicy' in data: - isAvailable = data[ - 'musicItemRendererDisplayPolicy'] != 'MUSIC_ITEM_RENDERER_DISPLAY_POLICY_GREY_OUT' + 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, 'menuNavigationItemRenderer', 'navigationEndpoint'] - + NAVIGATION_VIDEO_TYPE, True) + data, + MENU_ITEMS + [0, "menuNavigationItemRenderer", "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 + "videoId": videoId, + "title": title, + "artists": artists, + "album": album, + "likeStatus": like, + "inLibrary": library_status, + "thumbnails": thumbnails, + "isAvailable": isAvailable, + "isExplicit": isExplicit, + "videoType": videoType, } if duration: - song['duration'] = duration - song['duration_seconds'] = parse_duration(duration) + song["duration"] = duration + song["duration_seconds"] = parse_duration(duration) if setVideoId: - song['setVideoId'] = setVideoId + song["setVideoId"] = setVideoId if feedback_tokens: - song['feedbackTokens'] = feedback_tokens + song["feedbackTokens"] = feedback_tokens if menu_entries: for menu_entry in menu_entries: diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 96d724b0..32488bae 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -1,15 +1,15 @@ -from .songs import * from ._utils import * +from .songs import * def get_search_result_type(result_type_local, result_types_local): if not result_type_local: return None - result_types = ['artist', 'playlist', 'song', 'video', 'station', 'profile', 'podcast', 'episode'] + result_types = ["artist", "playlist", "song", "video", "station", "profile", "podcast", "episode"] result_type_local = result_type_local.lower() # default to album since it's labeled with multiple values ('Single', 'EP', etc.) if result_type_local not in result_types_local: - result_type = 'album' + result_type = "album" else: result_type = result_types[result_types_local.index(result_type_local)] @@ -18,218 +18,214 @@ def get_search_result_type(result_type_local, result_types_local): def parse_top_result(data, search_result_types): result_type = get_search_result_type(nav(data, SUBTITLE), search_result_types) - search_result = {'category': nav(data, CARD_SHELF_TITLE), 'resultType': result_type} - if result_type == 'artist': + search_result = {"category": nav(data, CARD_SHELF_TITLE), "resultType": result_type} + if result_type == "artist": subscribers = nav(data, SUBTITLE2, True) if subscribers: - search_result['subscribers'] = subscribers.split(' ')[0] + search_result["subscribers"] = subscribers.split(" ")[0] - artist_info = parse_song_runs(nav(data, ['title', 'runs'])) + artist_info = parse_song_runs(nav(data, ["title", "runs"])) search_result.update(artist_info) - if result_type in ['song', 'video']: - on_tap = data.get('onTap') + if result_type in ["song", "video"]: + on_tap = data.get("onTap") if on_tap: - search_result['videoId'] = nav(on_tap, WATCH_VIDEO_ID) - search_result['videoType'] = nav(on_tap, NAVIGATION_VIDEO_TYPE) + search_result["videoId"] = nav(on_tap, WATCH_VIDEO_ID) + search_result["videoType"] = nav(on_tap, NAVIGATION_VIDEO_TYPE) - if result_type in ['song', 'video', 'album']: - search_result['title'] = nav(data, TITLE_TEXT) - runs = nav(data, ['subtitle', 'runs'])[2:] + if result_type in ["song", "video", "album"]: + search_result["title"] = nav(data, TITLE_TEXT) + runs = nav(data, ["subtitle", "runs"])[2:] song_info = parse_song_runs(runs) search_result.update(song_info) - if result_type in ['album']: - search_result['browseId'] = nav(data, NAVIGATION_BROWSE_ID, True) + if result_type in ["album"]: + search_result["browseId"] = nav(data, NAVIGATION_BROWSE_ID, True) - search_result['thumbnails'] = nav(data, THUMBNAILS, True) + search_result["thumbnails"] = nav(data, THUMBNAILS, True) return search_result def parse_search_result(data, search_result_types, result_type, category): default_offset = (not result_type or result_type == "album") * 2 - search_result = {'category': category} - video_type = nav(data, PLAY_BUTTON + ['playNavigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) + search_result = {"category": category} + video_type = nav(data, PLAY_BUTTON + ["playNavigationEndpoint"] + NAVIGATION_VIDEO_TYPE, True) if not result_type and video_type: - result_type = 'song' if video_type == 'MUSIC_VIDEO_TYPE_ATV' else 'video' + result_type = "song" if video_type == "MUSIC_VIDEO_TYPE_ATV" else "video" - result_type = get_search_result_type(get_item_text(data, 1), - search_result_types) if not result_type else result_type - search_result['resultType'] = result_type + result_type = ( + get_search_result_type(get_item_text(data, 1), search_result_types) + if not result_type + else result_type + ) + search_result["resultType"] = result_type - if result_type != 'artist': - search_result['title'] = get_item_text(data, 0) + if result_type != "artist": + search_result["title"] = get_item_text(data, 0) - if result_type == 'artist': - search_result['artist'] = get_item_text(data, 0) + if result_type == "artist": + search_result["artist"] = get_item_text(data, 0) parse_menu_playlists(data, search_result) - elif result_type == 'album': - search_result['type'] = get_item_text(data, 1) + elif result_type == "album": + search_result["type"] = get_item_text(data, 1) - elif result_type == 'playlist': - flex_item = get_flex_column_item(data, 1)['text']['runs'] + elif result_type == "playlist": + flex_item = get_flex_column_item(data, 1)["text"]["runs"] has_author = len(flex_item) == default_offset + 3 - search_result['itemCount'] = get_item_text(data, 1, - default_offset + has_author * 2).split(' ')[0] - search_result['author'] = None if not has_author else get_item_text( - data, 1, default_offset) + search_result["itemCount"] = get_item_text(data, 1, default_offset + has_author * 2).split(" ")[0] + search_result["author"] = None if not has_author else get_item_text(data, 1, default_offset) - elif result_type == 'station': - search_result['videoId'] = nav(data, NAVIGATION_VIDEO_ID) - search_result['playlistId'] = nav(data, NAVIGATION_PLAYLIST_ID) + elif result_type == "station": + search_result["videoId"] = nav(data, NAVIGATION_VIDEO_ID) + search_result["playlistId"] = nav(data, NAVIGATION_PLAYLIST_ID) - elif result_type == 'profile': - search_result['name'] = get_item_text(data, 1, 2, True) + elif result_type == "profile": + search_result["name"] = get_item_text(data, 1, 2, True) - elif result_type == 'song': - search_result['album'] = None - if 'menu' in data: + elif result_type == "song": + search_result["album"] = None + if "menu" in data: toggle_menu = find_object_by_key(nav(data, MENU_ITEMS), TOGGLE_MENU) if toggle_menu: - search_result['inLibrary'] = parse_song_library_status(toggle_menu) - search_result['feedbackTokens'] = parse_song_menu_tokens(toggle_menu) + search_result["inLibrary"] = parse_song_library_status(toggle_menu) + search_result["feedbackTokens"] = parse_song_menu_tokens(toggle_menu) - elif result_type == 'upload': + elif result_type == "upload": browse_id = nav(data, NAVIGATION_BROWSE_ID, True) if not browse_id: # song result - flex_items = [ - nav(get_flex_column_item(data, i), ['text', 'runs'], True) for i in range(2) - ] + flex_items = [nav(get_flex_column_item(data, i), ["text", "runs"], True) for i in range(2)] if flex_items[0]: - search_result['videoId'] = nav(flex_items[0][0], NAVIGATION_VIDEO_ID, True) - search_result['playlistId'] = nav(flex_items[0][0], NAVIGATION_PLAYLIST_ID, True) + search_result["videoId"] = nav(flex_items[0][0], NAVIGATION_VIDEO_ID, True) + search_result["playlistId"] = nav(flex_items[0][0], NAVIGATION_PLAYLIST_ID, True) if flex_items[1]: search_result.update(parse_song_runs(flex_items[1])) - search_result['resultType'] = 'song' + search_result["resultType"] = "song" else: # artist or album result - search_result['browseId'] = browse_id - if 'artist' in search_result['browseId']: - search_result['resultType'] = 'artist' + search_result["browseId"] = browse_id + if "artist" in search_result["browseId"]: + search_result["resultType"] = "artist" else: flex_item2 = get_flex_column_item(data, 1) - runs = [ - run['text'] for i, run in enumerate(flex_item2['text']['runs']) if i % 2 == 0 - ] + runs = [run["text"] for i, run in enumerate(flex_item2["text"]["runs"]) if i % 2 == 0] if len(runs) > 1: - search_result['artist'] = runs[1] + search_result["artist"] = runs[1] if len(runs) > 2: # date may be missing - search_result['releaseDate'] = runs[2] - search_result['resultType'] = 'album' - - if result_type in ['song', 'video']: - search_result['videoId'] = nav( - data, PLAY_BUTTON + ['playNavigationEndpoint', 'watchEndpoint', 'videoId'], True) - search_result['videoType'] = video_type - - if result_type in ['song', 'video', 'album']: - search_result['duration'] = None - search_result['year'] = None + search_result["releaseDate"] = runs[2] + search_result["resultType"] = "album" + + if result_type in ["song", "video"]: + search_result["videoId"] = nav( + data, PLAY_BUTTON + ["playNavigationEndpoint", "watchEndpoint", "videoId"], True + ) + search_result["videoType"] = video_type + + if result_type in ["song", "video", "album"]: + search_result["duration"] = None + search_result["year"] = None flex_item = get_flex_column_item(data, 1) - runs = flex_item['text']['runs'][default_offset:] + runs = flex_item["text"]["runs"][default_offset:] song_info = parse_song_runs(runs) search_result.update(song_info) - if result_type in ['artist', 'album', 'playlist', 'profile']: - search_result['browseId'] = nav(data, NAVIGATION_BROWSE_ID, True) + if result_type in ["artist", "album", "playlist", "profile"]: + search_result["browseId"] = nav(data, NAVIGATION_BROWSE_ID, True) - if result_type in ['song', 'album']: - search_result['isExplicit'] = nav(data, BADGE_LABEL, True) is not None + if result_type in ["song", "album"]: + search_result["isExplicit"] = nav(data, BADGE_LABEL, True) is not None - search_result['thumbnails'] = nav(data, THUMBNAILS, True) + search_result["thumbnails"] = nav(data, THUMBNAILS, True) return search_result def parse_search_results(results, search_result_types, resultType=None, category=None): return [ - parse_search_result(result[MRLIR], search_result_types, resultType, category) - for result in results + parse_search_result(result[MRLIR], search_result_types, resultType, category) for result in results ] def get_search_params(filter, scope, ignore_spelling): - filtered_param1 = 'EgWKAQ' + filtered_param1 = "EgWKAQ" params = None if filter is None and scope is None and not ignore_spelling: return params - if scope == 'uploads': - params = 'agIYAw%3D%3D' + if scope == "uploads": + params = "agIYAw%3D%3D" - if scope == 'library': + if scope == "library": if filter: param1 = filtered_param1 param2 = _get_param2(filter) - param3 = 'AWoKEAUQCRADEAoYBA%3D%3D' + param3 = "AWoKEAUQCRADEAoYBA%3D%3D" else: - params = 'agIYBA%3D%3D' + params = "agIYBA%3D%3D" if scope is None and filter: - if filter == 'playlists': - params = 'Eg-KAQwIABAAGAAgACgB' + if filter == "playlists": + params = "Eg-KAQwIABAAGAAgACgB" if not ignore_spelling: - params += 'MABqChAEEAMQCRAFEAo%3D' + params += "MABqChAEEAMQCRAFEAo%3D" else: - params += 'MABCAggBagoQBBADEAkQBRAK' + params += "MABCAggBagoQBBADEAkQBRAK" - elif 'playlists' in filter: - param1 = 'EgeKAQQoA' - if filter == 'featured_playlists': - param2 = 'Dg' + elif "playlists" in filter: + param1 = "EgeKAQQoA" + if filter == "featured_playlists": + param2 = "Dg" else: # community_playlists - param2 = 'EA' + param2 = "EA" if not ignore_spelling: - param3 = 'BagwQDhAKEAMQBBAJEAU%3D' + param3 = "BagwQDhAKEAMQBBAJEAU%3D" else: - param3 = 'BQgIIAWoMEA4QChADEAQQCRAF' + param3 = "BQgIIAWoMEA4QChADEAQQCRAF" else: param1 = filtered_param1 param2 = _get_param2(filter) if not ignore_spelling: - param3 = 'AWoMEA4QChADEAQQCRAF' + param3 = "AWoMEA4QChADEAQQCRAF" else: - param3 = 'AUICCAFqDBAOEAoQAxAEEAkQBQ%3D%3D' + param3 = "AUICCAFqDBAOEAoQAxAEEAkQBQ%3D%3D" if not scope and not filter and ignore_spelling: - params = 'EhGKAQ4IARABGAEgASgAOAFAAUICCAE%3D' + params = "EhGKAQ4IARABGAEgASgAOAFAAUICCAE%3D" return params if params else param1 + param2 + param3 def _get_param2(filter): filter_params = { - 'songs': 'II', - 'videos': 'IQ', - 'albums': 'IY', - 'artists': 'Ig', - 'playlists': 'Io', - 'profiles': 'JY', - 'podcasts': 'JQ', - 'episodes': 'JI' + "songs": "II", + "videos": "IQ", + "albums": "IY", + "artists": "Ig", + "playlists": "Io", + "profiles": "JY", + "podcasts": "JQ", + "episodes": "JI", } return filter_params[filter] def parse_search_suggestions(results, detailed_runs): - if not results.get('contents', [{}])[0].get('searchSuggestionsSectionRenderer', {}).get( - 'contents', []): + if not results.get("contents", [{}])[0].get("searchSuggestionsSectionRenderer", {}).get("contents", []): return [] - raw_suggestions = results['contents'][0]['searchSuggestionsSectionRenderer']['contents'] + raw_suggestions = results["contents"][0]["searchSuggestionsSectionRenderer"]["contents"] suggestions = [] for raw_suggestion in raw_suggestions: - suggestion_content = raw_suggestion['searchSuggestionRenderer'] + suggestion_content = raw_suggestion["searchSuggestionRenderer"] - text = suggestion_content['navigationEndpoint']['searchEndpoint']['query'] - runs = suggestion_content['suggestion']['runs'] + text = suggestion_content["navigationEndpoint"]["searchEndpoint"]["query"] + runs = suggestion_content["suggestion"]["runs"] if detailed_runs: - suggestions.append({'text': text, 'runs': runs}) + suggestions.append({"text": text, "runs": runs}) else: suggestions.append(text) diff --git a/ytmusicapi/parsers/songs.py b/ytmusicapi/parsers/songs.py index 1c65d733..9347b51a 100644 --- a/ytmusicapi/parsers/songs.py +++ b/ytmusicapi/parsers/songs.py @@ -1,70 +1,64 @@ -from ._utils import * import re +from ._utils import * + def parse_song_artists(data, index): flex_item = get_flex_column_item(data, index) if not flex_item: return None else: - runs = flex_item['text']['runs'] + runs = flex_item["text"]["runs"] return parse_song_artists_runs(runs) def parse_song_artists_runs(runs): artists = [] for j in range(int(len(runs) / 2) + 1): - artists.append({ - 'name': runs[j * 2]['text'], - 'id': nav(runs[j * 2], NAVIGATION_BROWSE_ID, True) - }) + artists.append({"name": runs[j * 2]["text"], "id": nav(runs[j * 2], NAVIGATION_BROWSE_ID, True)}) return artists def parse_song_runs(runs): - parsed = {'artists': []} + parsed = {"artists": []} for i, run in enumerate(runs): if i % 2: # uneven items are always separators continue - text = run['text'] - if 'navigationEndpoint' in run: # artist or album - item = {'name': text, 'id': nav(run, NAVIGATION_BROWSE_ID, True)} + text = run["text"] + if "navigationEndpoint" in run: # artist or album + item = {"name": text, "id": nav(run, NAVIGATION_BROWSE_ID, True)} - if item['id'] and (item['id'].startswith('MPRE') - or "release_detail" in item['id']): # album - parsed['album'] = item + if item["id"] and (item["id"].startswith("MPRE") or "release_detail" in item["id"]): # album + parsed["album"] = item else: # artist - parsed['artists'].append(item) + parsed["artists"].append(item) else: # note: YT uses non-breaking space \xa0 to separate number and magnitude if re.match(r"^\d([^ ])* [^ ]*$", text) and i > 0: - parsed['views'] = text.split(' ')[0] + parsed["views"] = text.split(" ")[0] elif re.match(r"^(\d+:)*\d+:\d+$", text): - parsed['duration'] = text - parsed['duration_seconds'] = parse_duration(text) + parsed["duration"] = text + parsed["duration_seconds"] = parse_duration(text) elif re.match(r"^\d{4}$", text): - parsed['year'] = text + parsed["year"] = text else: # artist without id - parsed['artists'].append({'name': text, 'id': None}) + parsed["artists"].append({"name": text, "id": None}) return parsed def parse_song_album(data, index): flex_item = get_flex_column_item(data, index) - return None if not flex_item else { - 'name': get_item_text(data, index), - 'id': get_browse_id(flex_item, 0) - } + return None if not flex_item else {"name": get_item_text(data, index), "id": get_browse_id(flex_item, 0)} def parse_song_library_status(item) -> bool: """Returns True if song is in the library""" - library_status = nav(item, [TOGGLE_MENU, 'defaultIcon', 'iconType'], True) + library_status = nav(item, [TOGGLE_MENU, "defaultIcon", "iconType"], True) return library_status == "LIBRARY_SAVED" @@ -72,16 +66,16 @@ def parse_song_library_status(item) -> bool: def parse_song_menu_tokens(item): toggle_menu = item[TOGGLE_MENU] - library_add_token = nav(toggle_menu, ['defaultServiceEndpoint'] + FEEDBACK_TOKEN, True) - library_remove_token = nav(toggle_menu, ['toggledServiceEndpoint'] + FEEDBACK_TOKEN, True) + library_add_token = nav(toggle_menu, ["defaultServiceEndpoint"] + FEEDBACK_TOKEN, True) + library_remove_token = nav(toggle_menu, ["toggledServiceEndpoint"] + FEEDBACK_TOKEN, True) in_library = parse_song_library_status(item) if in_library: library_add_token, library_remove_token = library_remove_token, library_add_token - return {'add': library_add_token, 'remove': library_remove_token} + return {"add": library_add_token, "remove": library_remove_token} def parse_like_status(service): - status = ['LIKE', 'INDIFFERENT'] - return status[status.index(service['likeEndpoint']['status']) - 1] + status = ["LIKE", "INDIFFERENT"] + return status[status.index(service["likeEndpoint"]["status"]) - 1] diff --git a/ytmusicapi/parsers/uploads.py b/ytmusicapi/parsers/uploads.py index 2c7e11ac..91023d7b 100644 --- a/ytmusicapi/parsers/uploads.py +++ b/ytmusicapi/parsers/uploads.py @@ -1,34 +1,35 @@ from ._utils import * -from .songs import parse_song_artists, parse_song_album +from .songs import parse_song_album, parse_song_artists def parse_uploaded_items(results): songs = [] for result in results: data = result[MRLIR] - if 'menu' not in data: + if "menu" not in data: continue - entityId = nav(data, MENU_ITEMS)[-1]['menuNavigationItemRenderer']['navigationEndpoint'][ - 'confirmDialogEndpoint']['content']['confirmDialogRenderer']['confirmButton'][ - 'buttonRenderer']['command']['musicDeletePrivatelyOwnedEntityCommand']['entityId'] + entityId = nav(data, MENU_ITEMS)[-1]["menuNavigationItemRenderer"]["navigationEndpoint"][ + "confirmDialogEndpoint" + ]["content"]["confirmDialogRenderer"]["confirmButton"]["buttonRenderer"]["command"][ + "musicDeletePrivatelyOwnedEntityCommand" + ]["entityId"] - videoId = nav(data, MENU_ITEMS + [0] - + MENU_SERVICE)['queueAddEndpoint']['queueTarget']['videoId'] + videoId = nav(data, MENU_ITEMS + [0] + MENU_SERVICE)["queueAddEndpoint"]["queueTarget"]["videoId"] title = get_item_text(data, 0) like = nav(data, MENU_LIKE_STATUS) - thumbnails = nav(data, THUMBNAILS) if 'thumbnail' in data else None - duration = get_fixed_column_item(data, 0)['text']['runs'][0]['text'] + thumbnails = nav(data, THUMBNAILS) if "thumbnail" in data else None + duration = get_fixed_column_item(data, 0)["text"]["runs"][0]["text"] song = { - 'entityId': entityId, - 'videoId': videoId, - 'title': title, - 'duration': duration, - 'duration_seconds': parse_duration(duration), - 'artists': parse_song_artists(data, 1), - 'album': parse_song_album(data, 2), - 'likeStatus': like, - 'thumbnails': thumbnails + "entityId": entityId, + "videoId": videoId, + "title": title, + "duration": duration, + "duration_seconds": parse_duration(duration), + "artists": parse_song_artists(data, 1), + "album": parse_song_album(data, 2), + "likeStatus": like, + "thumbnails": thumbnails, } songs.append(song) diff --git a/ytmusicapi/parsers/watch.py b/ytmusicapi/parsers/watch.py index d1a3cf5f..8f28dd6f 100644 --- a/ytmusicapi/parsers/watch.py +++ b/ytmusicapi/parsers/watch.py @@ -4,22 +4,22 @@ def parse_watch_playlist(results): tracks = [] - PPVWR = 'playlistPanelVideoWrapperRenderer' - PPVR = 'playlistPanelVideoRenderer' + PPVWR = "playlistPanelVideoWrapperRenderer" + PPVR = "playlistPanelVideoRenderer" for result in results: counterpart = None if PPVWR in result: - counterpart = result[PPVWR]['counterpart'][0]['counterpartRenderer'][PPVR] - result = result[PPVWR]['primaryRenderer'] + counterpart = result[PPVWR]["counterpart"][0]["counterpartRenderer"][PPVR] + result = result[PPVWR]["primaryRenderer"] if PPVR not in result: continue data = result[PPVR] - if 'unplayableText' in data: + if "unplayableText" in data: continue track = parse_watch_track(data) if counterpart: - track['counterpart'] = parse_watch_track(counterpart) + track["counterpart"] = parse_watch_track(counterpart) tracks.append(track) return tracks @@ -30,31 +30,30 @@ def parse_watch_track(data): for item in nav(data, MENU_ITEMS): if TOGGLE_MENU in item: library_status = parse_song_library_status(item) - service = item[TOGGLE_MENU]['defaultServiceEndpoint'] - if 'feedbackEndpoint' in service: + service = item[TOGGLE_MENU]["defaultServiceEndpoint"] + if "feedbackEndpoint" in service: feedback_tokens = parse_song_menu_tokens(item) - if 'likeEndpoint' in service: + if "likeEndpoint" in service: like_status = parse_like_status(service) - song_info = parse_song_runs(data['longBylineText']['runs']) + song_info = parse_song_runs(data["longBylineText"]["runs"]) track = { - 'videoId': data['videoId'], - 'title': nav(data, TITLE_TEXT), - 'length': nav(data, ['lengthText', 'runs', 0, 'text'], True), - 'thumbnail': nav(data, THUMBNAIL), - 'feedbackTokens': feedback_tokens, - 'likeStatus': like_status, - 'inLibrary': library_status, - 'videoType': nav(data, ['navigationEndpoint'] + NAVIGATION_VIDEO_TYPE, True) + "videoId": data["videoId"], + "title": nav(data, TITLE_TEXT), + "length": nav(data, ["lengthText", "runs", 0, "text"], True), + "thumbnail": nav(data, THUMBNAIL), + "feedbackTokens": feedback_tokens, + "likeStatus": like_status, + "inLibrary": library_status, + "videoType": nav(data, ["navigationEndpoint"] + NAVIGATION_VIDEO_TYPE, True), } track.update(song_info) return track def get_tab_browse_id(watchNextRenderer, tab_id): - if 'unselectable' not in watchNextRenderer['tabs'][tab_id]['tabRenderer']: - return watchNextRenderer['tabs'][tab_id]['tabRenderer']['endpoint']['browseEndpoint'][ - 'browseId'] + if "unselectable" not in watchNextRenderer["tabs"][tab_id]["tabRenderer"]: + return watchNextRenderer["tabs"][tab_id]["tabRenderer"]["endpoint"]["browseEndpoint"]["browseId"] else: return None diff --git a/ytmusicapi/setup.py b/ytmusicapi/setup.py index f826ec41..67da6de9 100644 --- a/ytmusicapi/setup.py +++ b/ytmusicapi/setup.py @@ -22,12 +22,14 @@ def setup(filepath: str = None, headers_raw: str = None) -> Dict: return setup_browser(filepath, headers_raw) -def setup_oauth(filepath: str = None, - session: requests.Session = None, - proxies: dict = None, - open_browser: bool = False, - client_id: str = None, - client_secret: str = None) -> Dict: +def setup_oauth( + filepath: str = None, + session: requests.Session = None, + proxies: dict = None, + open_browser: bool = False, + client_id: str = None, + client_secret: str = None, +) -> Dict: """ Starts oauth flow from the terminal and returns a string that can be passed to YTMusic() @@ -55,11 +57,8 @@ def setup_oauth(filepath: str = None, def parse_args(args): - parser = argparse.ArgumentParser(description='Setup ytmusicapi.') - parser.add_argument("setup_type", - type=str, - choices=["oauth", "browser"], - help="choose a setup type.") + parser = argparse.ArgumentParser(description="Setup ytmusicapi.") + parser.add_argument("setup_type", type=str, choices=["oauth", "browser"], help="choose a setup type.") parser.add_argument("--file", type=Path, help="optional path to output file.") return parser.parse_args(args) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 75bf9408..870f4a24 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -1,43 +1,47 @@ -import requests import gettext import os -from functools import partial +import time from contextlib import suppress +from functools import partial from typing import Dict, Optional -import time +import requests from requests.structures import CaseInsensitiveDict -from ytmusicapi.parsers.i18n import Parser + from ytmusicapi.helpers import * from ytmusicapi.mixins.browsing import BrowsingMixin -from ytmusicapi.mixins.search import SearchMixin -from ytmusicapi.mixins.watch import WatchMixin from ytmusicapi.mixins.explore import ExploreMixin from ytmusicapi.mixins.library import LibraryMixin from ytmusicapi.mixins.playlists import PlaylistsMixin +from ytmusicapi.mixins.search import SearchMixin from ytmusicapi.mixins.uploads import UploadsMixin +from ytmusicapi.mixins.watch import WatchMixin +from ytmusicapi.parsers.i18n import Parser -from .auth.oauth import OAuthCredentials, RefreshingToken, OAuthToken +from .auth.oauth import OAuthCredentials, OAuthToken, RefreshingToken from .auth.oauth.base import Token from .auth.types import AuthType -class YTMusic(BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, - UploadsMixin): +class YTMusic( + BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, UploadsMixin +): """ Allows automated interactions with YouTube Music by emulating the YouTube web client's requests. Permits both authenticated and non-authenticated requests. Authentication header data must be provided on initialization. """ - def __init__(self, - auth: Optional[str | Dict] = None, - user: str = None, - requests_session=True, - proxies: Dict = None, - language: str = 'en', - location: str = '', - oauth_credentials: Optional[OAuthCredentials] = None): + def __init__( + self, + auth: Optional[str | Dict] = None, + user: str = None, + requests_session=True, + proxies: Dict = None, + language: str = "en", + location: str = "", + oauth_credentials: Optional[OAuthCredentials] = None, + ): """ Create a new instance to interact with YouTube Music. @@ -101,9 +105,11 @@ def __init__(self, # see google cookie docs: https://policies.google.com/technologies/cookies # value from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L502 - self.cookies = {'SOCS': 'CAI'} + self.cookies = {"SOCS": "CAI"} if self.auth is not None: - self.oauth_credentials = oauth_credentials if oauth_credentials is not None else OAuthCredentials() + self.oauth_credentials = ( + oauth_credentials if oauth_credentials is not None else OAuthCredentials() + ) auth_filepath = None if isinstance(self.auth, str): if os.path.isfile(auth): @@ -128,31 +134,32 @@ def __init__(self, if location: if location not in SUPPORTED_LOCATIONS: raise Exception("Location not supported. Check the FAQ for supported locations.") - self.context['context']['client']['gl'] = location + self.context["context"]["client"]["gl"] = location if language not in SUPPORTED_LANGUAGES: - raise Exception("Language not supported. Supported languages are " - + (', '.join(SUPPORTED_LANGUAGES)) + ".") - self.context['context']['client']['hl'] = language + raise Exception( + "Language not supported. Supported languages are " + (", ".join(SUPPORTED_LANGUAGES)) + "." + ) + self.context["context"]["client"]["hl"] = language self.language = language try: locale.setlocale(locale.LC_ALL, self.language) except locale.Error: with suppress(locale.Error): - locale.setlocale(locale.LC_ALL, 'en_US.UTF-8') + locale.setlocale(locale.LC_ALL, "en_US.UTF-8") - locale_dir = os.path.abspath(os.path.dirname(__file__)) + os.sep + 'locales' - self.lang = gettext.translation('base', localedir=locale_dir, languages=[language]) + locale_dir = os.path.abspath(os.path.dirname(__file__)) + os.sep + "locales" + self.lang = gettext.translation("base", localedir=locale_dir, languages=[language]) self.parser = Parser(self.lang) if user: - self.context['context']['user']['onBehalfOfUser'] = user + self.context["context"]["user"]["onBehalfOfUser"] = user auth_headers = self._input_dict.get("authorization") if auth_headers: if "SAPISIDHASH" in auth_headers: self.auth_type = AuthType.BROWSER - elif auth_headers.startswith('Bearer'): + elif auth_headers.startswith("Bearer"): self.auth_type = AuthType.OAUTH_CUSTOM_FULL # sapsid, origin, and params all set once during init @@ -160,9 +167,9 @@ def __init__(self, if self.auth_type == AuthType.BROWSER: self.params += YTM_PARAMS_KEY try: - cookie = self.base_headers.get('cookie') + cookie = self.base_headers.get("cookie") self.sapisid = sapisid_from_cookie(cookie) - self.origin = self.base_headers.get('origin', self.base_headers.get('x-origin')) + self.origin = self.base_headers.get("origin", self.base_headers.get("x-origin")) except KeyError: raise Exception("Your cookie is missing the required value __Secure-3PAPISID") @@ -178,7 +185,7 @@ def base_headers(self): "accept-encoding": "gzip, deflate", "content-type": "application/json", "content-encoding": "gzip", - "origin": YTM_DOMAIN + "origin": YTM_DOMAIN, } return self._base_headers @@ -191,11 +198,11 @@ def headers(self): # keys updated each use, custom oauth implementations left untouched if self.auth_type == AuthType.BROWSER: - self._headers["authorization"] = get_authorization(self.sapisid + ' ' + self.origin) + self._headers["authorization"] = get_authorization(self.sapisid + " " + self.origin) elif self.auth_type in AuthType.oauth_types(): - self._headers['authorization'] = self._token.as_auth() - self._headers['X-Goog-Request-Time'] = str(int(time.time())) + self._headers["authorization"] = self._token.as_auth() + self._headers["X-Goog-Request-Time"] = str(int(time.time())) return self._headers @@ -203,19 +210,20 @@ def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") - body.update(self.context) # only required for post requests (?) - if 'X-Goog-Visitor-Id' not in self.headers: + if "X-Goog-Visitor-Id" not in self.headers: self._headers.update(get_visitor_id(self._send_get_request)) - response = self._session.post(YTM_BASE_API + endpoint + self.params + additionalParams, - json=body, - headers=self.headers, - proxies=self.proxies, - cookies=self.cookies) + response = self._session.post( + YTM_BASE_API + endpoint + self.params + additionalParams, + json=body, + headers=self.headers, + proxies=self.proxies, + cookies=self.cookies, + ) response_text = json.loads(response.text) if response.status_code >= 400: - message = "Server returned HTTP " + str( - response.status_code) + ": " + response.reason + ".\n" - error = response_text.get('error', {}).get('message') + message = "Server returned HTTP " + str(response.status_code) + ": " + response.reason + ".\n" + error = response_text.get("error", {}).get("message") raise Exception(message + error) return response_text @@ -226,7 +234,8 @@ def _send_get_request(self, url: str, params: Dict = None): # handle first-use x-goog-visitor-id fetching headers=self.headers if self._headers else self.base_headers, proxies=self.proxies, - cookies=self.cookies) + cookies=self.cookies, + ) return response def _check_auth(self): From 1971121adde944adfff23126b87e31f77dec922f Mon Sep 17 00:00:00 2001 From: John Birdwell <76758414+jcbirdwell@users.noreply.github.com> Date: Sun, 31 Dec 2023 06:16:48 -0600 Subject: [PATCH 221/238] Search issue 494 (#495) * search_suggestions: fix handle history when authenticated * search_suggestions: test historical option does something * remove optional param --------- Co-authored-by: sigma67 --- tests/test.py | 9 +++++++++ ytmusicapi/parsers/search.py | 9 +++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/test.py b/tests/test.py index 0e1825f0..e9a7f4b1 100644 --- a/tests/test.py +++ b/tests/test.py @@ -322,6 +322,15 @@ def test_get_search_suggestions(self): result = self.yt.get_search_suggestions("fade", detailed_runs=True) self.assertGreaterEqual(len(result), 0) + # add search term to history + first_pass = self.yt_auth.search("b") + self.assertGreater(len(first_pass), 0) + # get results + results = self.yt_auth.get_search_suggestions("b", detailed_runs=True) + self.assertGreater(len(results), 0) + self.assertTrue(any(item["fromHistory"] for item in results)) + self.assertTrue(any(not item["fromHistory"] for item in results)) + ################ # EXPLORE ################ diff --git a/ytmusicapi/parsers/search.py b/ytmusicapi/parsers/search.py index 32488bae..05bde37c 100644 --- a/ytmusicapi/parsers/search.py +++ b/ytmusicapi/parsers/search.py @@ -219,13 +219,18 @@ def parse_search_suggestions(results, detailed_runs): suggestions = [] for raw_suggestion in raw_suggestions: - suggestion_content = raw_suggestion["searchSuggestionRenderer"] + if "historySuggestionRenderer" in raw_suggestion: + suggestion_content = raw_suggestion["historySuggestionRenderer"] + from_history = True + else: + suggestion_content = raw_suggestion["searchSuggestionRenderer"] + from_history = False text = suggestion_content["navigationEndpoint"]["searchEndpoint"]["query"] runs = suggestion_content["suggestion"]["runs"] if detailed_runs: - suggestions.append({"text": text, "runs": runs}) + suggestions.append({"text": text, "runs": runs, "fromHistory": from_history}) else: suggestions.append(text) From f3e747103f33007c775a2fddfb9a18ae940b9728 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 31 Dec 2023 14:39:05 +0100 Subject: [PATCH 222/238] get_album: return new "views" key for each track --- tests/test.py | 6 +++++- ytmusicapi/parsers/playlists.py | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/test.py b/tests/test.py index e9a7f4b1..b223ae57 100644 --- a/tests/test.py +++ b/tests/test.py @@ -266,6 +266,8 @@ def test_get_album(self): results = self.yt_auth.get_album(sample_album) self.assertGreaterEqual(len(results), 9) self.assertTrue(results["tracks"][0]["isExplicit"]) + self.assertTrue(all(item["views"] is not None for item in results["tracks"])) + self.assertTrue(all(item["album"] is not None for item in results["tracks"])) self.assertIn("feedbackTokens", results["tracks"][0]) self.assertGreaterEqual(len(results["other_versions"]), 1) # appears to be regional results = self.yt.get_album("MPREb_BQZvl3BFGay") @@ -483,7 +485,9 @@ def test_subscribe_artists(self): def test_get_playlist_foreign(self): self.assertRaises(Exception, self.yt.get_playlist, "PLABC") - playlist = self.yt.get_playlist("PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", limit=300, suggestions_limit=7) + playlist = self.yt_auth.get_playlist( + "PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", limit=300, suggestions_limit=7 + ) self.assertGreater(len(playlist["duration"]), 5) self.assertGreater(len(playlist["tracks"]), 200) self.assertNotIn("suggestions", playlist) diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index a626f963..59c5ee2d 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -49,6 +49,12 @@ def parse_playlist_items(results, menu_entries: List[List] = None): album = parse_song_album(data, 2) + views = None + if album and album["id"] is None: + # views currently only present on albums and formatting is localization-dependent -> no parsing + if (views := (get_item_text(data, 2))) is not None: + album = None + duration = None if "fixedColumns" in data: if "simpleText" in get_fixed_column_item(data, 0)["text"]: @@ -85,6 +91,7 @@ def parse_playlist_items(results, menu_entries: List[List] = None): "isAvailable": isAvailable, "isExplicit": isExplicit, "videoType": videoType, + "views": views, } if duration: song["duration"] = duration From 70a260b516bda0e4c2911037e6ba24448ef0fae1 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 31 Dec 2023 19:55:38 +0100 Subject: [PATCH 223/238] add mypy --- .github/workflows/lint.yml | 13 ++++- .pre-commit-config.yaml | 17 +++---- CONTRIBUTING.rst | 4 +- pdm.lock | 77 ++++++++++++++++++++++++++++- pyproject.toml | 7 +++ tests/test.py | 1 + ytmusicapi/auth/browser.py | 3 +- ytmusicapi/auth/oauth/base.py | 76 +++++++++++++++------------- ytmusicapi/auth/oauth/refreshing.py | 9 ++-- ytmusicapi/mixins/_protocol.py | 28 +++++++++++ ytmusicapi/mixins/browsing.py | 17 ++++--- ytmusicapi/mixins/explore.py | 11 +++-- ytmusicapi/mixins/library.py | 30 +++++------ ytmusicapi/mixins/playlists.py | 38 ++++++++------ ytmusicapi/mixins/search.py | 11 +++-- ytmusicapi/mixins/uploads.py | 11 +++-- ytmusicapi/mixins/watch.py | 19 ++++--- ytmusicapi/navigation.py | 4 +- ytmusicapi/parsers/i18n.py | 6 +-- ytmusicapi/parsers/playlists.py | 7 ++- ytmusicapi/parsers/watch.py | 5 +- ytmusicapi/setup.py | 18 +++---- ytmusicapi/ytmusic.py | 48 ++++++++++++------ 23 files changed, 312 insertions(+), 148 deletions(-) create mode 100644 ytmusicapi/mixins/_protocol.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 978318d7..e5508d13 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -name: Ruff +name: lint on: pull_request: @@ -13,4 +13,13 @@ jobs: - uses: chartboost/ruff-action@v1 - uses: chartboost/ruff-action@v1 with: - args: format --check \ No newline at end of file + args: format --check + mypy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v3 + with: + python-version: "3.11" + - run: pip install mypy==1.8.0 + - run: mypy --install-types --non-interactive \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b7e27caf..95407a2d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,9 @@ repos: -- repo: https://github.com/pre-commit/mirrors-yapf - rev: v0.32.0 - hooks: - - id: yapf - additional_dependencies: [toml] -- repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 +- repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.1.9 + hooks: + # Run the linter. + - id: ruff + # Run the formatter. + - id: ruff-format \ No newline at end of file diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index c212c22c..8d0c2f17 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -17,7 +17,9 @@ Before making changes to the code, install the development requirements using .. code-block:: - pip install -e .[dev] + pip install pipx + pipx install pdm pre-commit + pdm install Before committing, stage your files and run style and linter checks: diff --git a/pdm.lock b/pdm.lock index 73114f5c..0e1fff8e 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:e8454691de99746f0fe6ba42b53f33bd3f0b8928021df648441b496e8c9a2f11" +content_hash = "sha256:b26172c266ad5d5a9ad9604af3348c8f2577fd45938709849735bda1cdbd9f2d" [[package]] name = "alabaster" @@ -324,6 +324,58 @@ files = [ {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"}, ] +[[package]] +name = "mypy" +version = "1.8.0" +requires_python = ">=3.8" +summary = "Optional static typing for Python" +groups = ["dev"] +dependencies = [ + "mypy-extensions>=1.0.0", + "tomli>=1.1.0; python_version < \"3.11\"", + "typing-extensions>=4.1.0", +] +files = [ + {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, + {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, + {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, + {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, + {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, + {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, + {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, + {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, + {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, + {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, + {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, + {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, + {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, + {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, + {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, + {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, + {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, + {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, + {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, + {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, + {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, + {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, + {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +requires_python = ">=3.5" +summary = "Type system extensions for programs checked with the mypy type checker." +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "packaging" version = "23.2" @@ -536,6 +588,29 @@ files = [ {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, ] +[[package]] +name = "tomli" +version = "2.0.1" +requires_python = ">=3.7" +summary = "A lil' TOML parser" +groups = ["dev"] +marker = "python_version < \"3.11\"" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.9.0" +requires_python = ">=3.8" +summary = "Backported and Experimental Type Hints for Python 3.8+" +groups = ["dev"] +files = [ + {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, + {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, +] + [[package]] name = "urllib3" version = "2.1.0" diff --git a/pyproject.toml b/pyproject.toml index 314a7032..5b5c5609 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,10 +44,17 @@ extend-select = [ "I", # isort ] +[tool.mypy] +files = [ + "ytmusicapi/" +] +mypy_path = "ytmusicapi" + [tool.pdm.dev-dependencies] dev = [ "coverage>=7.4.0", 'sphinx<7', 'sphinx-rtd-theme', "ruff>=0.1.9", + "mypy>=1.8.0", ] \ No newline at end of file diff --git a/tests/test.py b/tests/test.py index b223ae57..67d4fb17 100644 --- a/tests/test.py +++ b/tests/test.py @@ -327,6 +327,7 @@ def test_get_search_suggestions(self): # add search term to history first_pass = self.yt_auth.search("b") self.assertGreater(len(first_pass), 0) + time.sleep(3) # get results results = self.yt_auth.get_search_suggestions("b", detailed_runs=True) self.assertGreater(len(results), 0) diff --git a/ytmusicapi/auth/browser.py b/ytmusicapi/auth/browser.py index 34dda063..5ad13757 100644 --- a/ytmusicapi/auth/browser.py +++ b/ytmusicapi/auth/browser.py @@ -1,5 +1,6 @@ import os import platform +from typing import Optional from requests.structures import CaseInsensitiveDict @@ -13,7 +14,7 @@ def is_browser(headers: CaseInsensitiveDict) -> bool: return all(key in headers for key in browser_structure) -def setup_browser(filepath=None, headers_raw=None): +def setup_browser(filepath: Optional[str] = None, headers_raw: Optional[str] = None) -> str: contents = [] if not headers_raw: eof = "Ctrl-D" if platform.system() != "Windows" else "'Enter, Ctrl-Z, Enter'" diff --git a/ytmusicapi/auth/oauth/base.py b/ytmusicapi/auth/oauth/base.py index c92c4362..dbbe4c6d 100644 --- a/ytmusicapi/auth/oauth/base.py +++ b/ytmusicapi/auth/oauth/base.py @@ -1,6 +1,7 @@ import json import time -from typing import Dict, Optional +from abc import ABC +from typing import Mapping, Optional from requests.structures import CaseInsensitiveDict @@ -13,7 +14,7 @@ class Credentials: client_id: str client_secret: str - def get_code(self) -> Dict: + def get_code(self) -> Mapping: raise NotImplementedError() def token_from_code(self, device_code: str) -> RefreshableTokenDict: @@ -23,17 +24,17 @@ def refresh_token(self, refresh_token: str) -> BaseTokenDict: raise NotImplementedError() -class Token: +class Token(ABC): """Base class representation of the YouTubeMusicAPI OAuth token.""" - access_token: str - refresh_token: str - expires_in: int - expires_at: int - is_expiring: bool + _access_token: str + _refresh_token: str + _expires_in: int + _expires_at: int + _is_expiring: bool - scope: DefaultScope - token_type: Bearer + _scope: DefaultScope + _token_type: Bearer def __repr__(self) -> str: """Readable version.""" @@ -57,6 +58,34 @@ def as_auth(self) -> str: """Returns Authorization header ready str of token_type and access_token.""" return f"{self.token_type} {self.access_token}" + @property + def access_token(self) -> str: + return self._access_token + + @property + def refresh_token(self) -> str: + return self._refresh_token + + @property + def token_type(self) -> Bearer: + return self._token_type + + @property + def scope(self) -> DefaultScope: + return self._scope + + @property + def expires_at(self) -> int: + return self._expires_at + + @property + def expires_in(self) -> int: + return self._expires_in + + @property + def is_expiring(self) -> bool: + return self.expires_in < 60 + class OAuthToken(Token): """Wrapper for an OAuth token implementing expiration methods.""" @@ -68,7 +97,7 @@ def __init__( scope: str, token_type: str, expires_at: Optional[int] = None, - expires_in: Optional[int] = None, + expires_in: int = 0, ): """ @@ -84,10 +113,11 @@ def __init__( self._access_token = access_token self._refresh_token = refresh_token self._scope = scope + self._token_type = token_type # set/calculate token expiration using current epoch - self._expires_at: int = expires_at if expires_at else int(time.time() + expires_in) - self._token_type = token_type + self._expires_at: int = expires_at if expires_at else int(time.time()) + expires_in + self._expires_in: int = expires_in @staticmethod def is_oauth(headers: CaseInsensitiveDict) -> bool: @@ -109,26 +139,6 @@ def update(self, fresh_access: BaseTokenDict): self._access_token = fresh_access["access_token"] self._expires_at = int(time.time() + fresh_access["expires_in"]) - @property - def access_token(self) -> str: - return self._access_token - - @property - def refresh_token(self) -> str: - return self._refresh_token - - @property - def token_type(self) -> Bearer: - return self._token_type - - @property - def scope(self) -> DefaultScope: - return self._scope - - @property - def expires_at(self) -> int: - return self._expires_at - @property def expires_in(self) -> int: return int(self.expires_at - time.time()) diff --git a/ytmusicapi/auth/oauth/refreshing.py b/ytmusicapi/auth/oauth/refreshing.py index 2d48e8a5..e6243b52 100644 --- a/ytmusicapi/auth/oauth/refreshing.py +++ b/ytmusicapi/auth/oauth/refreshing.py @@ -47,6 +47,10 @@ def __init__(self, token: OAuthToken, credentials: Credentials, local_cache: Opt # values to new file location via setter self._local_cache = local_cache + @property + def token_type(self) -> Bearer: + return self.token.token_type + @property def local_cache(self) -> str | None: return self._local_cache @@ -78,11 +82,6 @@ def store_token(self, path: Optional[str] = None) -> None: with open(file_path, encoding="utf8", mode="w") as file: json.dump(self.token.as_dict(), file, indent=True) - @property - def token_type(self) -> Bearer: - # pass underlying value - return self.token.token_type - def as_dict(self) -> RefreshableTokenDict: # override base class method with call to underlying token's method return self.token.as_dict() diff --git a/ytmusicapi/mixins/_protocol.py b/ytmusicapi/mixins/_protocol.py new file mode 100644 index 00000000..41581b29 --- /dev/null +++ b/ytmusicapi/mixins/_protocol.py @@ -0,0 +1,28 @@ +"""protocol that defines the functions available to mixins""" +from typing import Dict, Optional, Protocol + +from requests import Response + +from ytmusicapi.auth.types import AuthType +from ytmusicapi.parsers.i18n import Parser + + +class MixinProtocol(Protocol): + """protocol that defines the functions available to mixins""" + + auth_type: AuthType + + parser: Parser + + headers: Dict[str, str] + + proxies: Optional[Dict[str, str]] + + def _check_auth(self) -> None: + pass + + def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") -> Dict: + pass + + def _send_get_request(self, url: str, params: Optional[Dict] = None) -> Response: + pass diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 2c99c823..f5b6e3b5 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -1,16 +1,19 @@ -from typing import Dict, List +import re +from typing import Any, Dict, List, Optional from ytmusicapi.continuations import get_continuations from ytmusicapi.helpers import YTM_DOMAIN, sum_total_duration from ytmusicapi.parsers.albums import parse_album_header -from ytmusicapi.parsers.browsing import * +from ytmusicapi.parsers.browsing import parse_album, parse_content_list, parse_mixed_content, parse_playlist from ytmusicapi.parsers.library import parse_albums from ytmusicapi.parsers.playlists import parse_playlist_items +from ..navigation import * +from ._protocol import MixinProtocol from ._utils import get_datestamp -class BrowsingMixin: +class BrowsingMixin(MixinProtocol): def get_home(self, limit=3) -> List[Dict]: """ Get the home page. @@ -217,7 +220,7 @@ def get_artist(self, channelId: str) -> Dict: response = self._send_request(endpoint, body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) - artist = {"description": None, "views": None} + artist: Dict[str, Any] = {"description": None, "views": None} header = response["header"]["musicImmersiveHeaderRenderer"] artist["name"] = nav(header, TITLE_TEXT) descriptionShelf = find_object_by_key(results, DESCRIPTION_SHELF[0], is_key=True) @@ -434,7 +437,7 @@ def get_album(self, browseId: str) -> Dict: return album - def get_song(self, videoId: str, signatureTimestamp: int = None) -> Dict: + def get_song(self, videoId: str, signatureTimestamp: Optional[int] = None) -> Dict: """ Returns metadata and streaming information about a song or video. @@ -743,7 +746,7 @@ def get_basejs_url(self): return YTM_DOMAIN + match.group(1) - def get_signatureTimestamp(self, url: str = None) -> int: + def get_signatureTimestamp(self, url: Optional[str] = None) -> int: """ Fetch the `base.js` script from YouTube Music and parse out the `signatureTimestamp` for use with :py:func:`get_song`. @@ -793,7 +796,7 @@ def get_tasteprofile(self) -> Dict: } return taste_profiles - def set_tasteprofile(self, artists: List[str], taste_profile: Dict = None) -> None: + def set_tasteprofile(self, artists: List[str], taste_profile: Optional[Dict] = None) -> None: """ Favorites artists to see more recommendations from the artist. Use :py:func:`get_tasteprofile` to see which artists are available to be recommended diff --git a/ytmusicapi/mixins/explore.py b/ytmusicapi/mixins/explore.py index c56266c2..6cfa7e6e 100644 --- a/ytmusicapi/mixins/explore.py +++ b/ytmusicapi/mixins/explore.py @@ -1,9 +1,10 @@ -from typing import Dict, List +from typing import Any, Dict, List +from ytmusicapi.mixins._protocol import MixinProtocol from ytmusicapi.parsers.explore import * -class ExploreMixin: +class ExploreMixin(MixinProtocol): def get_mood_categories(self) -> Dict: """ Fetch "Moods & Genres" categories from YouTube Music. @@ -49,7 +50,7 @@ def get_mood_categories(self) -> Dict: } """ - sections = {} + sections: Dict[str, Any] = {} response = self._send_request("browse", {"browseId": "FEmusic_moods_and_genres"}) for section in nav(response, SINGLE_COLUMN_TAB + SECTION_LIST): title = nav(section, GRID + ["header", "gridHeaderRenderer"] + TITLE_TEXT) @@ -187,13 +188,13 @@ def get_charts(self, country: str = "ZZ") -> Dict: } """ - body = {"browseId": "FEmusic_charts"} + body: Dict[str, Any] = {"browseId": "FEmusic_charts"} if country: body["formData"] = {"selectedValues": [country]} response = self._send_request("browse", body) results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST) - charts = {"countries": {}} + charts: Dict[str, Any] = {"countries": {}} menu = nav( results[0], MUSIC_SHELF diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index 3633960e..bde3b7a7 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -1,14 +1,15 @@ from random import randint -from typing import Dict, List +from typing import Dict, List, Optional from ytmusicapi.continuations import * from ytmusicapi.parsers.browsing import * from ytmusicapi.parsers.library import * +from ._protocol import MixinProtocol from ._utils import * -class LibraryMixin: +class LibraryMixin(MixinProtocol): def get_library_playlists(self, limit: int = 25) -> List[Dict]: """ Retrieves the playlists in the user's library. @@ -44,7 +45,7 @@ def get_library_playlists(self, limit: int = 25) -> List[Dict]: return playlists def get_library_songs( - self, limit: int = 25, validate_responses: bool = False, order: str = None + self, limit: int = 25, validate_responses: bool = False, order: Optional[str] = None ) -> List[Dict]: """ Gets the songs in the user's library (liked videos are not included). @@ -114,7 +115,7 @@ def get_library_songs( return songs - def get_library_albums(self, limit: int = 25, order: str = None) -> List[Dict]: + def get_library_albums(self, limit: int = 25, order: Optional[str] = None) -> List[Dict]: """ Gets the albums in the user's library. @@ -149,7 +150,7 @@ def get_library_albums(self, limit: int = 25, order: str = None) -> List[Dict]: response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_library_artists(self, limit: int = 25, order: str = None) -> List[Dict]: + def get_library_artists(self, limit: int = 25, order: Optional[str] = None) -> List[Dict]: """ Gets the artists of the songs in the user's library. @@ -177,7 +178,7 @@ def get_library_artists(self, limit: int = 25, order: str = None) -> List[Dict]: response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_library_subscriptions(self, limit: int = 25, order: str = None) -> List[Dict]: + def get_library_subscriptions(self, limit: int = 25, order: Optional[str] = None) -> List[Dict]: """ Gets the artists the user has subscribed to. @@ -196,15 +197,6 @@ def get_library_subscriptions(self, limit: int = 25, order: str = None) -> List[ response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_liked_songs(self, limit: int = 100) -> Dict: - """ - Gets playlist items for the 'Liked Songs' playlist - - :param limit: How many items to return. Default: 100 - :return: List of playlistItem dictionaries. See :py:func:`get_playlist` - """ - return self.get_playlist("LM", limit) - def get_history(self) -> List[Dict]: """ Gets your play history in reverse chronological order @@ -260,7 +252,7 @@ def remove_history_items(self, feedbackTokens: List[str]) -> Dict: # pragma: no return response - def rate_song(self, videoId: str, rating: str = "INDIFFERENT") -> Dict: + def rate_song(self, videoId: str, rating: str = "INDIFFERENT") -> Optional[Dict]: """ Rates a song ("thumbs up"/"thumbs down" interactions on YouTube Music) @@ -275,11 +267,11 @@ def rate_song(self, videoId: str, rating: str = "INDIFFERENT") -> Dict: body = {"target": {"videoId": videoId}} endpoint = prepare_like_endpoint(rating) if endpoint is None: - return + return None return self._send_request(endpoint, body) - def edit_song_library_status(self, feedbackTokens: List[str] = None) -> Dict: + def edit_song_library_status(self, feedbackTokens: Optional[List[str]] = None) -> Dict: """ Adds or removes a song from your library depending on the token provided. @@ -290,7 +282,7 @@ def edit_song_library_status(self, feedbackTokens: List[str] = None) -> Dict: self._check_auth() body = {"feedbackTokens": feedbackTokens} endpoint = "feedback" - return endpoint if not endpoint else self._send_request(endpoint, body) + return self._send_request(endpoint, body) def rate_playlist(self, playlistId: str, rating: str = "INDIFFERENT") -> Dict: """ diff --git a/ytmusicapi/mixins/playlists.py b/ytmusicapi/mixins/playlists.py index a7bcc0c4..c0a7b596 100644 --- a/ytmusicapi/mixins/playlists.py +++ b/ytmusicapi/mixins/playlists.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union from ytmusicapi.continuations import * from ytmusicapi.helpers import sum_total_duration, to_int @@ -6,10 +6,11 @@ from ytmusicapi.parsers.browsing import parse_content_list, parse_playlist from ytmusicapi.parsers.playlists import * +from ._protocol import MixinProtocol from ._utils import * -class PlaylistsMixin: +class PlaylistsMixin(MixinProtocol): def get_playlist( self, playlistId: str, limit: int = 100, related: bool = False, suggestions_limit: int = 0 ) -> Dict: @@ -196,13 +197,22 @@ def get_playlist( 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 + + :param limit: How many items to return. Default: 100 + :return: List of playlistItem dictionaries. See :py:func:`get_playlist` + """ + return self.get_playlist("LM", limit) + def create_playlist( self, title: str, description: str, privacy_status: str = "PRIVATE", - video_ids: List = None, - source_playlist: str = None, + video_ids: Optional[List] = None, + source_playlist: Optional[str] = None, ) -> Union[str, Dict]: """ Creates a new empty playlist and returns its id. @@ -233,11 +243,11 @@ def create_playlist( def edit_playlist( self, playlistId: str, - title: str = None, - description: str = None, - privacyStatus: str = None, - moveItem: Tuple[str, str] = None, - addPlaylistId: str = None, + title: Optional[str] = None, + description: Optional[str] = None, + privacyStatus: Optional[str] = None, + moveItem: Optional[Tuple[str, str]] = None, + addPlaylistId: Optional[str] = None, addToTop: Optional[bool] = None, ) -> Union[str, Dict]: """ @@ -255,7 +265,7 @@ def edit_playlist( :return: Status String or full response """ self._check_auth() - body = {"playlistId": validate_playlist_id(playlistId)} + body: Dict[str, Any] = {"playlistId": validate_playlist_id(playlistId)} actions = [] if title: actions.append({"action": "ACTION_SET_PLAYLIST_NAME", "playlistName": title}) @@ -305,8 +315,8 @@ def delete_playlist(self, playlistId: str) -> Union[str, Dict]: def add_playlist_items( self, playlistId: str, - videoIds: List[str] = None, - source_playlist: str = None, + videoIds: Optional[List[str]] = None, + source_playlist: Optional[str] = None, duplicates: bool = False, ) -> Union[str, Dict]: """ @@ -319,7 +329,7 @@ def add_playlist_items( :return: Status String and a dict containing the new setVideoId for each videoId or full response """ self._check_auth() - body = {"playlistId": validate_playlist_id(playlistId), "actions": []} + body: Dict[str, Any] = {"playlistId": validate_playlist_id(playlistId), "actions": []} if not videoIds and not source_playlist: raise Exception("You must provide either videoIds or a source_playlist to add to the playlist") @@ -363,7 +373,7 @@ def remove_playlist_items(self, playlistId: str, videos: List[Dict]) -> Union[st if len(videos) == 0: raise Exception("Cannot remove songs, because setVideoId is missing. Do you own this playlist?") - body = {"playlistId": validate_playlist_id(playlistId), "actions": []} + body: Dict[str, Any] = {"playlistId": validate_playlist_id(playlistId), "actions": []} for video in videos: body["actions"].append( { diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 783a48b3..ac9699c6 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -1,15 +1,16 @@ -from typing import Dict, List, Union +from typing import Any, Dict, List, Optional, Union from ytmusicapi.continuations import get_continuations +from ytmusicapi.mixins._protocol import MixinProtocol from ytmusicapi.parsers.search import * -class SearchMixin: +class SearchMixin(MixinProtocol): def search( self, query: str, - filter: str = None, - scope: str = None, + filter: Optional[str] = None, + scope: Optional[str] = None, limit: int = 20, ignore_spelling: bool = False, ) -> List[Dict]: @@ -134,7 +135,7 @@ def search( """ body = {"query": query} endpoint = "search" - search_results = [] + search_results: List[Dict[str, Any]] = [] filters = [ "albums", "artists", diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index 1f87842d..98955a28 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -1,6 +1,6 @@ import ntpath import os -from typing import Dict, List, Union +from typing import Dict, List, Optional, Union import requests @@ -17,11 +17,12 @@ from ytmusicapi.parsers.uploads import parse_uploaded_items from ..auth.types import AuthType +from ._protocol import MixinProtocol from ._utils import prepare_order_params, validate_order_parameter -class UploadsMixin: - def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[Dict]: +class UploadsMixin(MixinProtocol): + def get_library_upload_songs(self, limit: int = 25, order: Optional[str] = None) -> List[Dict]: """ Returns a list of uploaded songs @@ -68,7 +69,7 @@ def get_library_upload_songs(self, limit: int = 25, order: str = None) -> List[D return songs - def get_library_upload_albums(self, limit: int = 25, order: str = None) -> List[Dict]: + def get_library_upload_albums(self, limit: int = 25, order: Optional[str] = None) -> List[Dict]: """ Gets the albums of uploaded songs in the user's library. @@ -87,7 +88,7 @@ def get_library_upload_albums(self, limit: int = 25, order: str = None) -> List[ response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_library_upload_artists(self, limit: int = 25, order: str = None) -> List[Dict]: + def get_library_upload_artists(self, limit: int = 25, order: Optional[str] = None) -> List[Dict]: """ Gets the artists of uploaded songs in the user's library. diff --git a/ytmusicapi/mixins/watch.py b/ytmusicapi/mixins/watch.py index 4e5fc37a..f5839eba 100644 --- a/ytmusicapi/mixins/watch.py +++ b/ytmusicapi/mixins/watch.py @@ -1,19 +1,20 @@ -from typing import Dict, List, Union +from typing import Dict, List, Optional, Union from ytmusicapi.continuations import get_continuations +from ytmusicapi.mixins._protocol import MixinProtocol from ytmusicapi.parsers.playlists import validate_playlist_id from ytmusicapi.parsers.watch import * -class WatchMixin: +class WatchMixin(MixinProtocol): def get_watch_playlist( self, - videoId: str = None, - playlistId: str = None, + videoId: Optional[str] = None, + playlistId: Optional[str] = None, limit=25, radio: bool = False, shuffle: bool = False, - ) -> Dict[str, Union[List[Dict]]]: + ) -> Dict[str, Union[List[Dict], str, None]]: """ Get a watch list of tracks. This watch playlist appears when you press play on a track in YouTube Music. @@ -120,8 +121,12 @@ def get_watch_playlist( "musicVideoType": "MUSIC_VIDEO_TYPE_ATV", } } - body["playlistId"] = validate_playlist_id(playlistId) - is_playlist = body["playlistId"].startswith("PL") or body["playlistId"].startswith("OLA") + is_playlist = False + if playlistId: + playlist_id = validate_playlist_id(playlistId) + is_playlist = playlist_id.startswith("PL") or playlist_id.startswith("OLA") + body["playlistId"] = playlist_id + if shuffle and playlistId is not None: body["params"] = "wAEB8gECKAE%3D" if radio: diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 6b2bfd8c..4a0411ec 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -1,4 +1,6 @@ # commonly used navigation paths +from typing import Any, Dict, List + CONTENT = ["contents", 0] RUN_TEXT = ["runs", 0, "text"] TAB_CONTENT = ["tabs", 0, "tabRenderer", "content"] @@ -67,7 +69,7 @@ FRAMEWORK_MUTATIONS = ["frameworkUpdates", "entityBatchUpdate", "mutations"] -def nav(root, items, none_if_absent=False): +def nav(root: Dict[str, Any], items: List[Any], none_if_absent: bool = False) -> Any: """Access a nested object in root by item sequence.""" try: for k in items: diff --git a/ytmusicapi/parsers/i18n.py b/ytmusicapi/parsers/i18n.py index 289a4571..f8083044 100644 --- a/ytmusicapi/parsers/i18n.py +++ b/ytmusicapi/parsers/i18n.py @@ -1,4 +1,4 @@ -from typing import Dict, List +from typing import Any, Dict, List from ytmusicapi.navigation import CAROUSEL, CAROUSEL_TITLE, NAVIGATION_BROWSE_ID, nav from ytmusicapi.parsers._utils import i18n @@ -32,9 +32,9 @@ def get_search_result_types(self): @i18n def parse_artist_contents(self, results: List) -> Dict: categories = ["albums", "singles", "videos", "playlists", "related"] - categories_local = [_("albums"), _("singles"), _("videos"), _("playlists"), _("related")] + categories_local = [_("albums"), _("singles"), _("videos"), _("playlists"), _("related")] # type: ignore[name-defined] categories_parser = [parse_album, parse_single, parse_video, parse_playlist, parse_related_artist] - artist = {} + artist: Dict[str, Any] = {} for i, category in enumerate(categories): data = [ r["musicCarouselShelfRenderer"] diff --git a/ytmusicapi/parsers/playlists.py b/ytmusicapi/parsers/playlists.py index 59c5ee2d..75add749 100644 --- a/ytmusicapi/parsers/playlists.py +++ b/ytmusicapi/parsers/playlists.py @@ -1,10 +1,9 @@ -from typing import List +from typing import List, Optional -from ._utils import * from .songs import * -def parse_playlist_items(results, menu_entries: List[List] = None): +def parse_playlist_items(results, menu_entries: Optional[List[List]] = None): songs = [] for result in results: if MRLIR not in result: @@ -110,5 +109,5 @@ def parse_playlist_items(results, menu_entries: List[List] = None): return songs -def validate_playlist_id(playlistId): +def validate_playlist_id(playlistId: str) -> str: return playlistId if not playlistId.startswith("VL") else playlistId[2:] diff --git a/ytmusicapi/parsers/watch.py b/ytmusicapi/parsers/watch.py index 8f28dd6f..eac07779 100644 --- a/ytmusicapi/parsers/watch.py +++ b/ytmusicapi/parsers/watch.py @@ -1,8 +1,9 @@ -from ._utils import * +from typing import Any, Dict, List + from .songs import * -def parse_watch_playlist(results): +def parse_watch_playlist(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: tracks = [] PPVWR = "playlistPanelVideoWrapperRenderer" PPVR = "playlistPanelVideoRenderer" diff --git a/ytmusicapi/setup.py b/ytmusicapi/setup.py index 67da6de9..47d68bc5 100644 --- a/ytmusicapi/setup.py +++ b/ytmusicapi/setup.py @@ -1,15 +1,15 @@ import argparse import sys from pathlib import Path -from typing import Dict +from typing import Optional import requests from ytmusicapi.auth.browser import setup_browser -from ytmusicapi.auth.oauth import OAuthCredentials +from ytmusicapi.auth.oauth import OAuthCredentials, RefreshingToken -def setup(filepath: str = None, headers_raw: str = None) -> Dict: +def setup(filepath: Optional[str] = None, headers_raw: Optional[str] = None) -> str: """ Requests browser headers from the user via command line and returns a string that can be passed to YTMusic() @@ -23,13 +23,13 @@ def setup(filepath: str = None, headers_raw: str = None) -> Dict: def setup_oauth( - filepath: str = None, - session: requests.Session = None, - proxies: dict = None, + filepath: Optional[str] = None, + session: Optional[requests.Session] = None, + proxies: Optional[dict] = None, open_browser: bool = False, - client_id: str = None, - client_secret: str = None, -) -> Dict: + client_id: Optional[str] = None, + client_secret: Optional[str] = None, +) -> RefreshingToken: """ Starts oauth flow from the terminal and returns a string that can be passed to YTMusic() diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 870f4a24..81892718 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -1,4 +1,6 @@ import gettext +import json +import locale import os import time from contextlib import suppress @@ -6,9 +8,22 @@ from typing import Dict, Optional import requests +from requests import Response from requests.structures import CaseInsensitiveDict -from ytmusicapi.helpers import * +from ytmusicapi.helpers import ( + SUPPORTED_LANGUAGES, + SUPPORTED_LOCATIONS, + USER_AGENT, + YTM_BASE_API, + YTM_DOMAIN, + YTM_PARAMS, + YTM_PARAMS_KEY, + get_authorization, + get_visitor_id, + initialize_context, + sapisid_from_cookie, +) from ytmusicapi.mixins.browsing import BrowsingMixin from ytmusicapi.mixins.explore import ExploreMixin from ytmusicapi.mixins.library import LibraryMixin @@ -35,9 +50,9 @@ class YTMusic( def __init__( self, auth: Optional[str | Dict] = None, - user: str = None, + user: Optional[str] = None, requests_session=True, - proxies: Dict = None, + proxies: Optional[Dict[str, str]] = None, language: str = "en", location: str = "", oauth_credentials: Optional[OAuthCredentials] = None, @@ -84,7 +99,9 @@ def __init__( self._headers = None #: cache formed headers including auth self.auth = auth #: raw auth - self._input_dict = {} #: parsed auth arg value in dictionary format + self._input_dict: CaseInsensitiveDict = ( + CaseInsensitiveDict() + ) #: parsed auth arg value in dictionary format self.auth_type: AuthType = AuthType.UNAUTHORIZED @@ -92,16 +109,16 @@ def __init__( self.oauth_credentials: OAuthCredentials #: Client used for OAuth refreshing self._session: requests.Session #: request session for connection pooling - self.proxies: Dict = proxies #: params for session modification + self.proxies: Optional[Dict[str, str]] = proxies #: params for session modification if isinstance(requests_session, requests.Session): self._session = requests_session else: if requests_session: # Build a new session. self._session = requests.Session() - self._session.request = partial(self._session.request, timeout=30) + self._session.request = partial(self._session.request, timeout=30) # type: ignore[method-assign] else: # Use the Requests API module as a "session". - self._session = requests.api + self._session = requests.api # type: ignore[assignment] # see google cookie docs: https://policies.google.com/technologies/cookies # value from https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/youtube.py#L502 @@ -110,18 +127,19 @@ def __init__( self.oauth_credentials = ( oauth_credentials if oauth_credentials is not None else OAuthCredentials() ) - auth_filepath = None + auth_filepath: Optional[str] = None if isinstance(self.auth, str): - if os.path.isfile(auth): - with open(auth) as json_file: - auth_filepath = auth + auth_str: str = self.auth + if os.path.isfile(auth_str): + with open(auth_str) as json_file: + auth_filepath = auth_str input_json = json.load(json_file) else: - input_json = json.loads(auth) + input_json = json.loads(auth_str) self._input_dict = CaseInsensitiveDict(input_json) else: - self._input_dict = self.auth + self._input_dict = CaseInsensitiveDict(self.auth) if OAuthToken.is_oauth(self._input_dict): base_token = OAuthToken(**self._input_dict) @@ -210,7 +228,7 @@ def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") - body.update(self.context) # only required for post requests (?) - if "X-Goog-Visitor-Id" not in self.headers: + if self._headers and "X-Goog-Visitor-Id" not in self._headers: self._headers.update(get_visitor_id(self._send_get_request)) response = self._session.post( @@ -227,7 +245,7 @@ def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") - raise Exception(message + error) return response_text - def _send_get_request(self, url: str, params: Dict = None): + def _send_get_request(self, url: str, params: Optional[Dict] = None) -> Response: response = self._session.get( url, params=params, From e88792b4525587c056d12fe49cb573b8c7831f46 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Sun, 31 Dec 2023 20:24:31 +0100 Subject: [PATCH 224/238] remove flaky assert --- tests/test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test.py b/tests/test.py index 67d4fb17..9ed20d8e 100644 --- a/tests/test.py +++ b/tests/test.py @@ -325,13 +325,11 @@ def test_get_search_suggestions(self): self.assertGreaterEqual(len(result), 0) # add search term to history - first_pass = self.yt_auth.search("b") + first_pass = self.yt_brand.search("b") self.assertGreater(len(first_pass), 0) - time.sleep(3) # get results results = self.yt_auth.get_search_suggestions("b", detailed_runs=True) self.assertGreater(len(results), 0) - self.assertTrue(any(item["fromHistory"] for item in results)) self.assertTrue(any(not item["fromHistory"] for item in results)) ################ From 5e5daa3ae7246e4ba4d8b599b40c7dbc2342a869 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 1 Jan 2024 14:13:09 +0100 Subject: [PATCH 225/238] refactor oauth classes --- ytmusicapi/auth/oauth/__init__.py | 5 +- ytmusicapi/auth/oauth/base.py | 148 ------------------------- ytmusicapi/auth/oauth/credentials.py | 47 ++++---- ytmusicapi/auth/oauth/refreshing.py | 87 --------------- ytmusicapi/auth/oauth/token.py | 155 +++++++++++++++++++++++++++ ytmusicapi/mixins/browsing.py | 2 +- ytmusicapi/setup.py | 2 +- ytmusicapi/ytmusic.py | 7 +- 8 files changed, 187 insertions(+), 266 deletions(-) delete mode 100644 ytmusicapi/auth/oauth/base.py delete mode 100644 ytmusicapi/auth/oauth/refreshing.py create mode 100644 ytmusicapi/auth/oauth/token.py diff --git a/ytmusicapi/auth/oauth/__init__.py b/ytmusicapi/auth/oauth/__init__.py index f84e63fe..fa1f6624 100644 --- a/ytmusicapi/auth/oauth/__init__.py +++ b/ytmusicapi/auth/oauth/__init__.py @@ -1,5 +1,4 @@ -from .base import OAuthToken from .credentials import OAuthCredentials -from .refreshing import RefreshingToken +from .token import OAuthToken, RefreshingToken -__all__ = ["OAuthCredentials", "RefreshingToken", "OAuthToken"] +__all__ = ["OAuthCredentials", "OAuthToken", "RefreshingToken"] diff --git a/ytmusicapi/auth/oauth/base.py b/ytmusicapi/auth/oauth/base.py deleted file mode 100644 index dbbe4c6d..00000000 --- a/ytmusicapi/auth/oauth/base.py +++ /dev/null @@ -1,148 +0,0 @@ -import json -import time -from abc import ABC -from typing import Mapping, Optional - -from requests.structures import CaseInsensitiveDict - -from .models import BaseTokenDict, Bearer, DefaultScope, RefreshableTokenDict - - -class Credentials: - """Base class representation of YouTubeMusicAPI OAuth Credentials""" - - client_id: str - client_secret: str - - def get_code(self) -> Mapping: - raise NotImplementedError() - - def token_from_code(self, device_code: str) -> RefreshableTokenDict: - raise NotImplementedError() - - def refresh_token(self, refresh_token: str) -> BaseTokenDict: - raise NotImplementedError() - - -class Token(ABC): - """Base class representation of the YouTubeMusicAPI OAuth token.""" - - _access_token: str - _refresh_token: str - _expires_in: int - _expires_at: int - _is_expiring: bool - - _scope: DefaultScope - _token_type: Bearer - - def __repr__(self) -> str: - """Readable version.""" - return f"{self.__class__.__name__}: {self.as_dict()}" - - def as_dict(self) -> RefreshableTokenDict: - """Returns dictionary containing underlying token values.""" - return { - "access_token": self.access_token, - "refresh_token": self.refresh_token, - "scope": self.scope, - "expires_at": self.expires_at, - "expires_in": self.expires_in, - "token_type": self.token_type, - } - - def as_json(self) -> str: - return json.dumps(self.as_dict()) - - def as_auth(self) -> str: - """Returns Authorization header ready str of token_type and access_token.""" - return f"{self.token_type} {self.access_token}" - - @property - def access_token(self) -> str: - return self._access_token - - @property - def refresh_token(self) -> str: - return self._refresh_token - - @property - def token_type(self) -> Bearer: - return self._token_type - - @property - def scope(self) -> DefaultScope: - return self._scope - - @property - def expires_at(self) -> int: - return self._expires_at - - @property - def expires_in(self) -> int: - return self._expires_in - - @property - def is_expiring(self) -> bool: - return self.expires_in < 60 - - -class OAuthToken(Token): - """Wrapper for an OAuth token implementing expiration methods.""" - - def __init__( - self, - access_token: str, - refresh_token: str, - scope: str, - token_type: str, - expires_at: Optional[int] = None, - expires_in: int = 0, - ): - """ - - :param access_token: active oauth key - :param refresh_token: access_token's matching oauth refresh string - :param scope: most likely 'https://www.googleapis.com/auth/youtube' - :param token_type: commonly 'Bearer' - :param expires_at: Optional. Unix epoch (seconds) of access token expiration. - :param expires_in: Optional. Seconds till expiration, assumes/calculates epoch of init. - - """ - # match baseclass attribute/property format - self._access_token = access_token - self._refresh_token = refresh_token - self._scope = scope - self._token_type = token_type - - # set/calculate token expiration using current epoch - self._expires_at: int = expires_at if expires_at else int(time.time()) + expires_in - self._expires_in: int = expires_in - - @staticmethod - def is_oauth(headers: CaseInsensitiveDict) -> bool: - oauth_structure = { - "access_token", - "expires_at", - "expires_in", - "token_type", - "refresh_token", - } - return all(key in headers for key in oauth_structure) - - def update(self, fresh_access: BaseTokenDict): - """ - Update access_token and expiration attributes with a BaseTokenDict inplace. - expires_at attribute set using current epoch, avoid expiration desync - by passing only recently requested tokens dicts or updating values to compensate. - """ - self._access_token = fresh_access["access_token"] - self._expires_at = int(time.time() + fresh_access["expires_in"]) - - @property - def expires_in(self) -> int: - return int(self.expires_at - time.time()) - - @property - def is_expiring(self) -> bool: - return self.expires_in < 60 diff --git a/ytmusicapi/auth/oauth/credentials.py b/ytmusicapi/auth/oauth/credentials.py index bdf12f76..80104fff 100644 --- a/ytmusicapi/auth/oauth/credentials.py +++ b/ytmusicapi/auth/oauth/credentials.py @@ -1,5 +1,6 @@ -import webbrowser -from typing import Dict, Optional +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Dict, Mapping, Optional import requests @@ -12,10 +13,29 @@ OAUTH_USER_AGENT, ) -from .base import Credentials, OAuthToken from .exceptions import BadOAuthClient, UnauthorizedOAuthClient from .models import AuthCodeDict, BaseTokenDict, RefreshableTokenDict -from .refreshing import RefreshingToken + + +@dataclass +class Credentials(ABC): + """Base class representation of YouTubeMusicAPI OAuth Credentials""" + + client_id: str + client_secret: str + + @abstractmethod + def get_code(self) -> Mapping: + """Method for obtaining a new user auth code. First step of token creation.""" + + @abstractmethod + def token_from_code(self, device_code: str) -> RefreshableTokenDict: + """Method for verifying user auth code and conversion into a FullTokenDict.""" + + @abstractmethod + def refresh_token(self, refresh_token: str) -> BaseTokenDict: + """Method for requesting a new access token for a given refresh_token. + Token must have been created by the same OAuth client.""" class OAuthCredentials(Credentials): @@ -90,25 +110,6 @@ def token_from_code(self, device_code: str) -> RefreshableTokenDict: ) return response.json() - def prompt_for_token(self, open_browser: bool = False, to_file: Optional[str] = None) -> RefreshingToken: - """ - Method for CLI token creation via user inputs. - - :param open_browser: Optional. Open browser to OAuth consent url automatically. (Default = False). - :param to_file: Optional. Path to store/sync json version of resulting token. (Default = None). - """ - - code = self.get_code() - url = f"{code['verification_url']}?user_code={code['user_code']}" - if open_browser: - webbrowser.open(url) - input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") - raw_token = self.token_from_code(code["device_code"]) - ref_token = RefreshingToken(OAuthToken(**raw_token), credentials=self) - if to_file: - ref_token.local_cache = to_file - return ref_token - def refresh_token(self, refresh_token: str) -> BaseTokenDict: """ Method for requesting a new access token for a given refresh_token. diff --git a/ytmusicapi/auth/oauth/refreshing.py b/ytmusicapi/auth/oauth/refreshing.py deleted file mode 100644 index e6243b52..00000000 --- a/ytmusicapi/auth/oauth/refreshing.py +++ /dev/null @@ -1,87 +0,0 @@ -import json -import os -from typing import Optional - -from .base import Credentials, OAuthToken, Token -from .models import Bearer, RefreshableTokenDict - - -class RefreshingToken(Token): - """ - Compositional implementation of Token that automatically refreshes - an underlying OAuthToken when required (credential expiration <= 1 min) - upon access_token attribute access. - """ - - @classmethod - def from_file(cls, file_path: str, credentials: Credentials, sync=True): - """ - Initialize a refreshing token and underlying OAuthToken directly from a file. - - :param file_path: path to json containing token values - :param credentials: credentials used with token in file. - :param sync: Optional. Whether to pass the filepath into instance enabling file - contents to be updated upon refresh. (Default=True). - :return: RefreshingToken instance - :rtype: RefreshingToken - """ - - if os.path.isfile(file_path): - with open(file_path) as json_file: - file_pack = json.load(json_file) - - return cls(OAuthToken(**file_pack), credentials, file_path if sync else None) - - def __init__(self, token: OAuthToken, credentials: Credentials, local_cache: Optional[str] = None): - """ - :param token: Underlying Token being maintained. - :param credentials: OAuth client being used for refreshing. - :param local_cache: Optional. Path to json file where token values are stored. - When provided, file contents is updated upon token refresh. - """ - - self.token: OAuthToken = token #: internal token being used / refreshed / maintained - self.credentials = credentials #: credentials used for access_token refreshing - - #: protected/property attribute enables auto writing token - # values to new file location via setter - self._local_cache = local_cache - - @property - def token_type(self) -> Bearer: - return self.token.token_type - - @property - def local_cache(self) -> str | None: - return self._local_cache - - @local_cache.setter - def local_cache(self, path: str): - """Update attribute and dump token to new path.""" - self._local_cache = path - self.store_token() - - @property - def access_token(self) -> str: - if self.token.is_expiring: - fresh = self.credentials.refresh_token(self.token.refresh_token) - self.token.update(fresh) - self.store_token() - - return self.token.access_token - - def store_token(self, path: Optional[str] = None) -> None: - """ - Write token values to json file at specified path, defaulting to self.local_cache. - Operation does not update instance local_cache attribute. - Automatically called when local_cache is set post init. - """ - file_path = path if path else self.local_cache - - if file_path: - with open(file_path, encoding="utf8", mode="w") as file: - json.dump(self.token.as_dict(), file, indent=True) - - def as_dict(self) -> RefreshableTokenDict: - # override base class method with call to underlying token's method - return self.token.as_dict() diff --git a/ytmusicapi/auth/oauth/token.py b/ytmusicapi/auth/oauth/token.py new file mode 100644 index 00000000..563c3fff --- /dev/null +++ b/ytmusicapi/auth/oauth/token.py @@ -0,0 +1,155 @@ +import json +import os +import time +import webbrowser +from dataclasses import dataclass +from typing import Optional + +from requests.structures import CaseInsensitiveDict + +from ytmusicapi.auth.oauth.credentials import Credentials +from ytmusicapi.auth.oauth.models import BaseTokenDict, Bearer, DefaultScope, RefreshableTokenDict + + +@dataclass +class Token: + """Base class representation of the YouTubeMusicAPI OAuth token.""" + + scope: DefaultScope + token_type: Bearer + + access_token: str + refresh_token: str + expires_at: int + expires_in: int = 0 + + def __repr__(self) -> str: + """Readable version.""" + return f"{self.__class__.__name__}: {self.as_dict()}" + + def as_dict(self) -> RefreshableTokenDict: + """Returns dictionary containing underlying token values.""" + return { + "access_token": self.access_token, + "refresh_token": self.refresh_token, + "scope": self.scope, + "expires_at": self.expires_at, + "expires_in": self.expires_in, + "token_type": self.token_type, + } + + def as_json(self) -> str: + return json.dumps(self.as_dict()) + + def as_auth(self) -> str: + """Returns Authorization header ready str of token_type and access_token.""" + return f"{self.token_type} {self.access_token}" + + @property + def is_expiring(self) -> bool: + return self.expires_in < 60 + + +class OAuthToken(Token): + """Wrapper for an OAuth token implementing expiration methods.""" + + @staticmethod + def is_oauth(headers: CaseInsensitiveDict) -> bool: + oauth_structure = { + "access_token", + "expires_at", + "expires_in", + "token_type", + "refresh_token", + } + return all(key in headers for key in oauth_structure) + + def update(self, fresh_access: BaseTokenDict): + """ + Update access_token and expiration attributes with a BaseTokenDict inplace. + expires_at attribute set using current epoch, avoid expiration desync + by passing only recently requested tokens dicts or updating values to compensate. + """ + self.access_token = fresh_access["access_token"] + self.expires_at = int(time.time()) + fresh_access["expires_in"] + + @property + def is_expiring(self) -> bool: + return self.expires_at - int(time.time()) < 60 + + @classmethod + def from_json(cls, file_path: str) -> "OAuthToken": + if os.path.isfile(file_path): + with open(file_path) as json_file: + file_pack = json.load(json_file) + + return cls(**file_pack) + + +@dataclass +class RefreshingToken(OAuthToken): + """ + Compositional implementation of Token that automatically refreshes + an underlying OAuthToken when required (credential expiration <= 1 min) + upon access_token attribute access. + """ + + #: credentials used for access_token refreshing + credentials: Optional[Credentials] = None + + #: protected/property attribute enables auto writing token values to new file location via setter + _local_cache: Optional[str] = None + + def __getattr__(self, item): + """access token setter to auto-refresh if it is expiring""" + if item == "access_token" and self.is_expiring: + fresh = self.credentials.refresh_token(self.refresh_token) + self.update(fresh) + self.store_token() + + return super().__getattribute__(item) + + @property + def local_cache(self) -> Optional[str]: + return self._local_cache + + @local_cache.setter + def local_cache(self, path: str): + """Update attribute and dump token to new path.""" + self._local_cache = path + self.store_token() + + @classmethod + def prompt_for_token( + cls, credentials: Credentials, open_browser: bool = False, to_file: Optional[str] = None + ) -> "RefreshingToken": + """ + Method for CLI token creation via user inputs. + + :param credentials: Client credentials + :param open_browser: Optional. Open browser to OAuth consent url automatically. (Default = False). + :param to_file: Optional. Path to store/sync json version of resulting token. (Default = None). + """ + + code = credentials.get_code() + url = f"{code['verification_url']}?user_code={code['user_code']}" + if open_browser: + webbrowser.open(url) + input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") + raw_token = credentials.token_from_code(code["device_code"]) + ref_token = cls(credentials=credentials, **raw_token) + if to_file: + ref_token.local_cache = to_file + return ref_token + + def store_token(self, path: Optional[str] = None) -> None: + """ + Write token values to json file at specified path, defaulting to self.local_cache. + Operation does not update instance local_cache attribute. + Automatically called when local_cache is set post init. + """ + file_path = path if path else self.local_cache + + if file_path: + with open(file_path, encoding="utf8", mode="w") as file: + json.dump(self.as_dict(), file, indent=True) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index f5b6e3b5..75b49bbc 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -345,7 +345,7 @@ def get_user_playlists(self, channelId: str, params: str) -> List[Dict]: return user_playlists - def get_album_browse_id(self, audioPlaylistId: str) -> str | None: + def get_album_browse_id(self, audioPlaylistId: str) -> Optional[str]: """ Get an album's browseId based on its audioPlaylistId diff --git a/ytmusicapi/setup.py b/ytmusicapi/setup.py index 47d68bc5..a76c5dfd 100644 --- a/ytmusicapi/setup.py +++ b/ytmusicapi/setup.py @@ -53,7 +53,7 @@ def setup_oauth( else: oauth_credentials = OAuthCredentials(session=session, proxies=proxies) - return oauth_credentials.prompt_for_token(open_browser, filepath) + return RefreshingToken.prompt_for_token(oauth_credentials, open_browser, filepath) def parse_args(args): diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 81892718..147870ea 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -34,7 +34,7 @@ from ytmusicapi.parsers.i18n import Parser from .auth.oauth import OAuthCredentials, OAuthToken, RefreshingToken -from .auth.oauth.base import Token +from .auth.oauth.token import Token from .auth.types import AuthType @@ -142,8 +142,9 @@ def __init__( self._input_dict = CaseInsensitiveDict(self.auth) if OAuthToken.is_oauth(self._input_dict): - base_token = OAuthToken(**self._input_dict) - self._token = RefreshingToken(base_token, self.oauth_credentials, auth_filepath) + self._token = RefreshingToken( + credentials=self.oauth_credentials, _local_cache=auth_filepath, **self._input_dict + ) self.auth_type = AuthType.OAUTH_CUSTOM_CLIENT if oauth_credentials else AuthType.OAUTH_DEFAULT # prepare context From e0d73c02b22132a030398f6472b44d6f34df97e7 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 1 Jan 2024 14:21:53 +0100 Subject: [PATCH 226/238] fix tests --- tests/test.py | 13 ++++++------- ytmusicapi/auth/oauth/token.py | 2 +- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/test.py b/tests/test.py index 9ed20d8e..f25836b4 100644 --- a/tests/test.py +++ b/tests/test.py @@ -80,7 +80,6 @@ def test_setup_oauth(self, session_mock, json_mock): ############### # OAUTH ############### - # 000 so test is run first and fresh token is available to others def test_oauth_tokens(self): # ensure instance initialized token self.assertIsNotNone(self.yt_oauth._token) @@ -90,16 +89,16 @@ def test_oauth_tokens(self): first_json = json.load(f) # pull reference values from underlying token - first_token = self.yt_oauth._token.token.access_token - first_expire = self.yt_oauth._token.token.expires_at + first_token = self.yt_oauth._token.access_token + first_expire = self.yt_oauth._token.expires_at # make token expire - self.yt_oauth._token.token._expires_at = time.time() + self.yt_oauth._token.expires_at = time.time() # check - self.assertTrue(self.yt_oauth._token.token.is_expiring) + self.assertTrue(self.yt_oauth._token.is_expiring) # pull new values, assuming token will be refreshed on access second_token = self.yt_oauth._token.access_token - second_expire = self.yt_oauth._token.token.expires_at - second_token_inner = self.yt_oauth._token.token.access_token + second_expire = self.yt_oauth._token.expires_at + second_token_inner = self.yt_oauth._token.access_token # check it was refreshed self.assertNotEqual(first_token, second_token) # check expiration timestamps to confirm diff --git a/ytmusicapi/auth/oauth/token.py b/ytmusicapi/auth/oauth/token.py index 563c3fff..35804aa8 100644 --- a/ytmusicapi/auth/oauth/token.py +++ b/ytmusicapi/auth/oauth/token.py @@ -100,7 +100,7 @@ class RefreshingToken(OAuthToken): #: protected/property attribute enables auto writing token values to new file location via setter _local_cache: Optional[str] = None - def __getattr__(self, item): + def __getattribute__(self, item): """access token setter to auto-refresh if it is expiring""" if item == "access_token" and self.is_expiring: fresh = self.credentials.refresh_token(self.refresh_token) From fb48dcc103adb9ad1e14faa8c2199fee4537316a Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 1 Jan 2024 18:51:39 +0100 Subject: [PATCH 227/238] test_home: hotfix failing podcast entries on homepage --- ytmusicapi/parsers/browsing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ytmusicapi/parsers/browsing.py b/ytmusicapi/parsers/browsing.py index 2348395d..4b2a493e 100644 --- a/ytmusicapi/parsers/browsing.py +++ b/ytmusicapi/parsers/browsing.py @@ -32,7 +32,9 @@ def parse_mixed_content(rows): elif page_type == "MUSIC_PAGE_TYPE_PLAYLIST": content = parse_playlist(data) else: - data = nav(result, [MRLIR]) + data = nav(result, [MRLIR], True) + if not data: + continue content = parse_song_flat(data) contents.append(content) From 102a732f9b0df244c3d5e8112f6b51c9e679bec0 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 1 Jan 2024 19:23:14 +0100 Subject: [PATCH 228/238] simplify OAuth as_dict and is_oauth --- ytmusicapi/auth/oauth/token.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/ytmusicapi/auth/oauth/token.py b/ytmusicapi/auth/oauth/token.py index 35804aa8..0df8cb82 100644 --- a/ytmusicapi/auth/oauth/token.py +++ b/ytmusicapi/auth/oauth/token.py @@ -23,20 +23,17 @@ class Token: expires_at: int expires_in: int = 0 + @staticmethod + def members(): + return Token.__annotations__.keys() + def __repr__(self) -> str: """Readable version.""" return f"{self.__class__.__name__}: {self.as_dict()}" def as_dict(self) -> RefreshableTokenDict: """Returns dictionary containing underlying token values.""" - return { - "access_token": self.access_token, - "refresh_token": self.refresh_token, - "scope": self.scope, - "expires_at": self.expires_at, - "expires_in": self.expires_in, - "token_type": self.token_type, - } + return {key: self.__dict__[key] for key in Token.members()} # type: ignore def as_json(self) -> str: return json.dumps(self.as_dict()) @@ -55,14 +52,7 @@ class OAuthToken(Token): @staticmethod def is_oauth(headers: CaseInsensitiveDict) -> bool: - oauth_structure = { - "access_token", - "expires_at", - "expires_in", - "token_type", - "refresh_token", - } - return all(key in headers for key in oauth_structure) + return all(key in headers for key in Token.members()) def update(self, fresh_access: BaseTokenDict): """ From d70868c20a47a9ecf4b73877f8bd03e50251c52b Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 1 Jan 2024 19:27:44 +0100 Subject: [PATCH 229/238] update .pre-commit-config.yaml --- .pre-commit-config.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 95407a2d..a9699913 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,4 +6,9 @@ repos: # Run the linter. - id: ruff # Run the formatter. - - id: ruff-format \ No newline at end of file + - id: ruff-format +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + args: [--install-types, --non-interactive] From dcfc6c12609da7506f22511ddbb827cc8a6d2868 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 1 Jan 2024 19:39:39 +0100 Subject: [PATCH 230/238] fix pyproject.toml for release --- pyproject.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5b5c5609..e6b5f706 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ build-backend = "setuptools.build_meta" [tool.setuptools.dynamic] readme = {file = ["README.rst"]} +[tool.setuptools_scm] + [tool.setuptools] include-package-data=false @@ -57,4 +59,4 @@ dev = [ 'sphinx-rtd-theme', "ruff>=0.1.9", "mypy>=1.8.0", -] \ No newline at end of file +] From dc5bce5ff9f6ed5a4c4647a5eab443ee7b45d60b Mon Sep 17 00:00:00 2001 From: sigma67 Date: Mon, 1 Jan 2024 19:45:37 +0100 Subject: [PATCH 231/238] update pythonpublish.yml --- .github/workflows/pythonpublish.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/pythonpublish.yml b/.github/workflows/pythonpublish.yml index 5cfd4e9c..847a12cb 100644 --- a/.github/workflows/pythonpublish.yml +++ b/.github/workflows/pythonpublish.yml @@ -5,6 +5,7 @@ on: types: [published] permissions: + id-token: write # trusted publishing contents: read jobs: @@ -26,5 +27,3 @@ jobs: run: python -m build - name: Publish a Python distribution to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} From 0cb0a1604bd7c4053183d6d9f4adcb74fd6ea4e6 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 2 Jan 2024 21:52:18 +0100 Subject: [PATCH 232/238] fix some minor issues discovered by typing, enable composition --- ytmusicapi/mixins/_protocol.py | 6 ++++-- ytmusicapi/mixins/uploads.py | 2 +- ytmusicapi/navigation.py | 16 +++++++++++++--- ytmusicapi/ytmusic.py | 27 ++++++++++++++++++--------- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/ytmusicapi/mixins/_protocol.py b/ytmusicapi/mixins/_protocol.py index 41581b29..2983b5d0 100644 --- a/ytmusicapi/mixins/_protocol.py +++ b/ytmusicapi/mixins/_protocol.py @@ -14,8 +14,6 @@ class MixinProtocol(Protocol): parser: Parser - headers: Dict[str, str] - proxies: Optional[Dict[str, str]] def _check_auth(self) -> None: @@ -26,3 +24,7 @@ def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") - def _send_get_request(self, url: str, params: Optional[Dict] = None) -> Response: pass + + @property + def headers(self) -> Dict[str, str]: + pass diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index 98955a28..8fd5eb09 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -207,7 +207,7 @@ def upload_song(self, filepath: str) -> Union[str, requests.Response]: """ self._check_auth() if not self.auth_type == AuthType.BROWSER: - raise Exception("Please provide authentication before using this function") + raise Exception("Please provide browser authentication before using this function") if not os.path.isfile(filepath): raise Exception("The provided file does not exist.") diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 4a0411ec..e5c6d1ac 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -1,5 +1,5 @@ -# commonly used navigation paths -from typing import Any, Dict, List +"""commonly used navigation paths""" +from typing import Any, Dict, List, Literal, Optional, overload CONTENT = ["contents", 0] RUN_TEXT = ["runs", 0, "text"] @@ -69,7 +69,17 @@ FRAMEWORK_MUTATIONS = ["frameworkUpdates", "entityBatchUpdate", "mutations"] -def nav(root: Dict[str, Any], items: List[Any], none_if_absent: bool = False) -> Any: +@overload +def nav(root: Dict[str, Any], items: List[Any], none_if_absent: Literal[False] = False) -> Any: + ... + + +@overload +def nav(root: Dict, items: List[Any], none_if_absent: Literal[True] = True) -> Optional[Any]: + ... + + +def nav(root: Dict, items: List[Any], none_if_absent: bool = False) -> Optional[Any]: """Access a nested object in root by item sequence.""" try: for k in items: diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 147870ea..53d3f0fe 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -38,15 +38,7 @@ from .auth.types import AuthType -class YTMusic( - BrowsingMixin, SearchMixin, WatchMixin, ExploreMixin, LibraryMixin, PlaylistsMixin, UploadsMixin -): - """ - Allows automated interactions with YouTube Music by emulating the YouTube web client's requests. - Permits both authenticated and non-authenticated requests. - Authentication header data must be provided on initialization. - """ - +class YTMusicBase: def __init__( self, auth: Optional[str | Dict] = None, @@ -266,3 +258,20 @@ def __enter__(self): def __exit__(self, execType=None, execValue=None, trackback=None): pass + + +class YTMusic( + YTMusicBase, + BrowsingMixin, + SearchMixin, + WatchMixin, + ExploreMixin, + LibraryMixin, + PlaylistsMixin, + UploadsMixin, +): + """ + Allows automated interactions with YouTube Music by emulating the YouTube web client's requests. + Permits both authenticated and non-authenticated requests. + Authentication header data must be provided on initialization. + """ From d1ac87ce6e1afe2e0c06c130c74f6a2a036e18fc Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 2 Jan 2024 21:52:36 +0100 Subject: [PATCH 233/238] get_user_playlists: no longer errors on empty profile --- ytmusicapi/mixins/browsing.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 75b49bbc..a013e452 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -340,7 +340,10 @@ def get_user_playlists(self, channelId: str, params: str) -> List[Dict]: endpoint = "browse" body = {"browseId": channelId, "params": params} response = self._send_request(endpoint, body) - results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + GRID_ITEMS) + results = nav(response, SINGLE_COLUMN_TAB + SECTION_LIST_ITEM + GRID_ITEMS, True) + if not results: + return [] + user_playlists = parse_content_list(results, parse_playlist) return user_playlists From 69dd4a9f2f282b34069c93b945c3a13944a61437 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 2 Jan 2024 21:58:33 +0100 Subject: [PATCH 234/238] test restructuring via pytest --- .github/workflows/coverage.yml | 10 +- .gitignore | 1 + pdm.lock | 136 +++++++- pyproject.toml | 13 +- tests/README.rst | 5 +- tests/__init__.py | 15 + tests/auth/__init__.py | 0 tests/auth/test_browser.py | 17 + tests/auth/test_oauth.py | 101 ++++++ tests/conftest.py | 72 ++++ tests/mixins/test_browsing.py | 126 +++++++ tests/mixins/test_explore.py | 17 + tests/mixins/test_library.py | 105 ++++++ tests/mixins/test_playlists.py | 75 ++++ tests/mixins/test_search.py | 90 +++++ tests/mixins/test_uploads.py | 62 ++++ tests/mixins/test_watch.py | 16 + tests/test.py | 610 --------------------------------- tests/test_ytmusic.py | 6 + 19 files changed, 858 insertions(+), 619 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/auth/__init__.py create mode 100644 tests/auth/test_browser.py create mode 100644 tests/auth/test_oauth.py create mode 100644 tests/conftest.py create mode 100644 tests/mixins/test_browsing.py create mode 100644 tests/mixins/test_explore.py create mode 100644 tests/mixins/test_library.py create mode 100644 tests/mixins/test_playlists.py create mode 100644 tests/mixins/test_search.py create mode 100644 tests/mixins/test_uploads.py create mode 100644 tests/mixins/test_watch.py delete mode 100644 tests/test.py create mode 100644 tests/test_ytmusic.py diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index a12c5208..b11da693 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -17,24 +17,26 @@ jobs: uses: actions/setup-python@master with: python-version: 3.x + - name: Setup PDM + uses: pdm-project/setup-pdm@v3 - name: create-json uses: jsdaniell/create-json@v1.2.2 with: name: "oauth.json" dir: "tests/" json: ${{ secrets.OAUTH_JSON }} + - name: Install dependencies + run: pdm install - name: Generate coverage report env: HEADERS_AUTH: ${{ secrets.HEADERS_AUTH }} TEST_CFG: ${{ secrets.TEST_CFG }} run: | - pip install -e . - pip install coverage curl -o tests/test.mp3 https://www.kozco.com/tech/piano2-CoolEdit.mp3 cat <<< "$HEADERS_AUTH" > tests/browser.json cat <<< "$TEST_CFG" > tests/test.cfg - coverage run - coverage xml + pdm run pytest + pdm run coverage xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: diff --git a/.gitignore b/.gitignore index c89cb371..318a0ce8 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ venv*/ build dist .pdm-python +.venv diff --git a/pdm.lock b/pdm.lock index 0e1fff8e..b84be4f4 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["cross_platform", "inherit_metadata"] lock_version = "4.4.1" -content_hash = "sha256:b26172c266ad5d5a9ad9604af3348c8f2577fd45938709849735bda1cdbd9f2d" +content_hash = "sha256:ab467aa4e09402f8249d103fc719d3dd2f9bab661ea269227b9358a8c223fa6e" [[package]] name = "alabaster" @@ -202,6 +202,72 @@ files = [ {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, ] +[[package]] +name = "coverage" +version = "7.4.0" +extras = ["toml"] +requires_python = ">=3.8" +summary = "Code coverage measurement for Python" +groups = ["dev"] +dependencies = [ + "coverage==7.4.0", + "tomli; python_full_version <= \"3.11.0a6\"", +] +files = [ + {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"}, + {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"}, + {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"}, + {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"}, + {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"}, + {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"}, + {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"}, + {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"}, + {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"}, + {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"}, + {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"}, + {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"}, + {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"}, + {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"}, + {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"}, + {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"}, + {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"}, + {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"}, + {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"}, + {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"}, + {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"}, + {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"}, + {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"}, + {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"}, + {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"}, + {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"}, + {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"}, + {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"}, + {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"}, + {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"}, + {file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"}, + {file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"}, + {file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"}, + {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"}, + {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"}, + {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"}, + {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"}, + {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"}, + {file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"}, + {file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"}, + {file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"}, + {file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"}, + {file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"}, + {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"}, + {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"}, + {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"}, + {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"}, + {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"}, + {file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"}, + {file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"}, + {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"}, + {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, +] + [[package]] name = "docutils" version = "0.19" @@ -213,6 +279,18 @@ files = [ {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"}, ] +[[package]] +name = "exceptiongroup" +version = "1.2.0" +requires_python = ">=3.7" +summary = "Backport of PEP 654 (exception groups)" +groups = ["dev"] +marker = "python_version < \"3.11\"" +files = [ + {file = "exceptiongroup-1.2.0-py3-none-any.whl", hash = "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14"}, + {file = "exceptiongroup-1.2.0.tar.gz", hash = "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68"}, +] + [[package]] name = "idna" version = "3.6" @@ -250,6 +328,17 @@ files = [ {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +requires_python = ">=3.7" +summary = "brain-dead simple config-ini parsing" +groups = ["dev"] +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + [[package]] name = "jinja2" version = "3.1.2" @@ -387,6 +476,17 @@ files = [ {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, ] +[[package]] +name = "pluggy" +version = "1.3.0" +requires_python = ">=3.8" +summary = "plugin and hook calling mechanisms for python" +groups = ["dev"] +files = [ + {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, + {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, +] + [[package]] name = "pygments" version = "2.17.2" @@ -398,6 +498,40 @@ files = [ {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, ] +[[package]] +name = "pytest" +version = "7.4.4" +requires_python = ">=3.7" +summary = "pytest: simple powerful testing with Python" +groups = ["dev"] +dependencies = [ + "colorama; sys_platform == \"win32\"", + "exceptiongroup>=1.0.0rc8; python_version < \"3.11\"", + "iniconfig", + "packaging", + "pluggy<2.0,>=0.12", + "tomli>=1.0.0; python_version < \"3.11\"", +] +files = [ + {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, + {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, +] + +[[package]] +name = "pytest-cov" +version = "4.1.0" +requires_python = ">=3.7" +summary = "Pytest plugin for measuring coverage." +groups = ["dev"] +dependencies = [ + "coverage[toml]>=5.2.1", + "pytest>=4.6", +] +files = [ + {file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, + {file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, +] + [[package]] name = "pytz" version = "2023.3.post1" diff --git a/pyproject.toml b/pyproject.toml index e6b5f706..db28899a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,8 +36,17 @@ include-package-data=false [tool.setuptools.package-data] "*" = ["**.rst", "**.py", "**.mo"] +############### +# DEVELOPMENT # +############### + +[tool.pytest.ini_options] +python_functions = "test_*" +testpaths = ["tests"] +addopts = "--verbose --cov" + [tool.coverage.run] -command_line = "-m unittest discover tests" +source = ["ytmusicapi"] [tool.ruff] line-length = 110 @@ -59,4 +68,6 @@ dev = [ 'sphinx-rtd-theme', "ruff>=0.1.9", "mypy>=1.8.0", + "pytest>=7.4.4", + "pytest-cov>=4.1.0", ] diff --git a/tests/README.rst b/tests/README.rst index ff54d83f..3cacf5ce 100644 --- a/tests/README.rst +++ b/tests/README.rst @@ -30,8 +30,7 @@ Make sure you installed the dev requirements as explained in `CONTRIBUTING.rst < .. code-block:: bash - cd tests - coverage run -m unittest test.py + pdm run pytest -to generate a coverage report. \ No newline at end of file +to generate a coverage report. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..4f3ce510 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,15 @@ +from importlib.metadata import PackageNotFoundError, version + +from ytmusicapi.setup import setup, setup_oauth +from ytmusicapi.ytmusic import YTMusic + +try: + __version__ = version("ytmusicapi") +except PackageNotFoundError: + # package is not installed + pass + +__copyright__ = "Copyright 2023 sigma67" +__license__ = "MIT" +__title__ = "ytmusicapi" +__all__ = ["YTMusic", "setup_oauth", "setup"] diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/auth/test_browser.py b/tests/auth/test_browser.py new file mode 100644 index 00000000..c77c8715 --- /dev/null +++ b/tests/auth/test_browser.py @@ -0,0 +1,17 @@ +from unittest import mock + +import ytmusicapi.setup +from ytmusicapi.setup import main + + +class TestBrowser: + def test_setup_browser(self, config, browser_filepath: str): + headers = ytmusicapi.setup(browser_filepath, config["auth"]["headers_raw"]) + assert len(headers) >= 2 + headers_raw = config["auth"]["headers_raw"].split("\n") + with ( + mock.patch("sys.argv", ["ytmusicapi", "browser", "--file", browser_filepath]), + mock.patch("builtins.input", side_effect=(headers_raw + [EOFError()])), + ): + headers = main() + assert len(headers) >= 2 diff --git a/tests/auth/test_oauth.py b/tests/auth/test_oauth.py new file mode 100644 index 00000000..d2309283 --- /dev/null +++ b/tests/auth/test_oauth.py @@ -0,0 +1,101 @@ +import json +import time +from pathlib import Path +from typing import Any, Dict +from unittest import mock + +import pytest +from requests import Response + +from ytmusicapi.auth.types import AuthType +from ytmusicapi.constants import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET +from ytmusicapi.setup import main +from ytmusicapi.ytmusic import OAuthCredentials, YTMusic + + +@pytest.fixture(name="blank_code") +def fixture_blank_code() -> Dict[str, Any]: + return { + "device_code": "", + "user_code": "", + "expires_in": 1800, + "interval": 5, + "verification_url": "https://www.google.com/device", + } + + +@pytest.fixture(name="alt_oauth_credentials") +def fixture_alt_oauth_credentials() -> OAuthCredentials: + return OAuthCredentials(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) + + +@pytest.fixture(name="yt_alt_oauth") +def fixture_yt_alt_oauth(browser_filepath: str, alt_oauth_credentials: OAuthCredentials) -> YTMusic: + return YTMusic(browser_filepath, oauth_credentials=alt_oauth_credentials) + + +class TestOAuth: + @mock.patch("requests.Response.json") + @mock.patch("requests.Session.post") + def test_setup_oauth(self, session_mock, json_mock, oauth_filepath, blank_code, yt_oauth): + session_mock.return_value = Response() + fresh_token = yt_oauth._token.as_dict() + json_mock.side_effect = [blank_code, fresh_token] + with mock.patch("builtins.input", return_value="y"), mock.patch( + "sys.argv", ["ytmusicapi", "oauth", "--file", oauth_filepath] + ): + main() + assert Path(oauth_filepath).exists() + + json_mock.side_effect = None + with open(oauth_filepath, mode="r", encoding="utf8") as oauth_file: + string_oauth_token = oauth_file.read() + + YTMusic(string_oauth_token) + + def test_oauth_tokens(self, oauth_filepath: str, yt_oauth: YTMusic): + # ensure instance initialized token + assert yt_oauth._token is not None + + # set reference file + with open(oauth_filepath, "r") as f: + first_json = json.load(f) + + # pull reference values from underlying token + first_token = yt_oauth._token.access_token + first_expire = yt_oauth._token.expires_at + # make token expire + yt_oauth._token.expires_at = int(time.time()) + # check + assert yt_oauth._token.is_expiring + # pull new values, assuming token will be refreshed on access + second_token = yt_oauth._token.access_token + second_expire = yt_oauth._token.expires_at + second_token_inner = yt_oauth._token.access_token + # check it was refreshed + assert first_token != second_token + # check expiration timestamps to confirm + assert second_expire != first_expire + assert second_expire > time.time() + 60 + # check token is propagating properly + assert second_token == second_token_inner + + with open(oauth_filepath, "r") as f2: + second_json = json.load(f2) + + # ensure token is updating local file + assert first_json != second_json + + def test_oauth_custom_client( + self, alt_oauth_credentials: OAuthCredentials, oauth_filepath: str, yt_alt_oauth: YTMusic + ): + # ensure client works/ignores alt if browser credentials passed as auth + assert yt_alt_oauth.auth_type != AuthType.OAUTH_CUSTOM_CLIENT + with open(oauth_filepath, "r") as f: + token_dict = json.load(f) + # oauth token dict entry and alt + yt_alt_oauth = YTMusic(token_dict, oauth_credentials=alt_oauth_credentials) + assert yt_alt_oauth.auth_type == AuthType.OAUTH_CUSTOM_CLIENT + + def test_alt_oauth_request(self, yt_alt_oauth: YTMusic, sample_video): + yt_alt_oauth.get_watch_playlist(sample_video) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..29b5bfb8 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,72 @@ +import configparser +from pathlib import Path + +import pytest + +from ytmusicapi import YTMusic + + +def get_resource(file: str) -> str: + data_dir = Path(__file__).parent + return data_dir.joinpath(file).as_posix() + + +@pytest.fixture(name="config") +def fixture_config() -> configparser.RawConfigParser: + config = configparser.RawConfigParser() + config.read(get_resource("test.cfg"), "utf-8") + return config + + +@pytest.fixture(name="sample_album") +def fixture_sample_album() -> str: + """Eminem - Revival""" + return "MPREb_4pL8gzRtw1p" + + +@pytest.fixture(name="sample_video") +def fixture_sample_video() -> str: + """Oasis - Wonderwall""" + return "hpSrLjc5SMs" + + +@pytest.fixture(name="sample_playlist") +def fixture_sample_playlist() -> str: + """very large playlist""" + return "PL6bPxvf5dW5clc3y9wAoslzqUrmkZ5c-u" + + +@pytest.fixture(name="browser_filepath") +def fixture_browser_filepath(config) -> str: + return get_resource(config["auth"]["browser_file"]) + + +@pytest.fixture(name="oauth_filepath") +def fixture_oauth_filepath(config) -> str: + return get_resource(config["auth"]["oauth_file"]) + + +@pytest.fixture(name="yt") +def fixture_yt() -> YTMusic: + return YTMusic() + + +@pytest.fixture(name="yt_auth") +def fixture_yt_auth(browser_filepath) -> YTMusic: + """a non-brand account that is able to create uploads""" + return YTMusic(browser_filepath, location="GB") + + +@pytest.fixture(name="yt_oauth") +def fixture_yt_oauth(oauth_filepath) -> YTMusic: + return YTMusic(oauth_filepath) + + +@pytest.fixture(name="yt_brand") +def fixture_yt_brand(config) -> YTMusic: + return YTMusic(config["auth"]["headers"], config["auth"]["brand_account"]) + + +@pytest.fixture(name="yt_empty") +def fixture_yt_empty(config) -> YTMusic: + return YTMusic(config["auth"]["headers_empty"], config["auth"]["brand_account_empty"]) diff --git a/tests/mixins/test_browsing.py b/tests/mixins/test_browsing.py new file mode 100644 index 00000000..fc8677f8 --- /dev/null +++ b/tests/mixins/test_browsing.py @@ -0,0 +1,126 @@ +import warnings + +import pytest + + +class TestBrowsing: + def test_get_home(self, yt, yt_auth): + result = yt.get_home(limit=6) + assert len(result) >= 6 + result = yt_auth.get_home(limit=15) + assert len(result) >= 15 + + def test_get_artist(self, yt): + results = yt.get_artist("MPLAUCmMUZbaYdNH0bEd1PAlAqsA") + assert len(results) == 14 + + # test correctness of related artists + related = results["related"]["results"] + assert len( + [x for x in related if set(x.keys()) == {"browseId", "subscribers", "title", "thumbnails"}] + ) == len(related) + + results = yt.get_artist("UCLZ7tlKC06ResyDmEStSrOw") # no album year + assert len(results) >= 11 + + def test_get_artist_albums(self, yt): + artist = yt.get_artist("UCj5ZiBBqpe0Tg4zfKGHEFuQ") + results = yt.get_artist_albums(artist["albums"]["browseId"], artist["albums"]["params"]) + assert len(results) > 0 + + def test_get_artist_singles(self, yt): + artist = yt.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") + results = yt.get_artist_albums(artist["singles"]["browseId"], artist["singles"]["params"]) + assert len(results) > 0 + + def test_get_user(self, yt): + results = yt.get_user("UC44hbeRoCZVVMVg5z0FfIww") + assert len(results) == 3 + + def test_get_user_playlists(self, yt_auth): + results = yt_auth.get_user("UCPVhZsC2od1xjGhgEc2NEPQ") # Vevo playlists + results = yt_auth.get_user_playlists("UCPVhZsC2od1xjGhgEc2NEPQ", results["playlists"]["params"]) + assert len(results) > 100 + + def test_get_album_browse_id(self, yt, sample_album): + warnings.filterwarnings(action="ignore", category=DeprecationWarning) + browse_id = yt.get_album_browse_id("OLAK5uy_nMr9h2VlS-2PULNz3M3XVXQj_P3C2bqaY") + assert browse_id == sample_album + + def test_get_album_browse_id_issue_470(self, yt): + escaped_browse_id = yt.get_album_browse_id("OLAK5uy_nbMYyrfeg5ZgknoOsOGBL268hGxtcbnDM") + assert escaped_browse_id == "MPREb_scJdtUCpPE2" + + def test_get_album(self, yt, yt_auth, sample_album): + results = yt_auth.get_album(sample_album) + assert len(results) >= 9 + assert results["tracks"][0]["isExplicit"] + assert all(item["views"] is not None for item in results["tracks"]) + assert all(item["album"] is not None for item in results["tracks"]) + assert "feedbackTokens" in results["tracks"][0] + assert len(results["other_versions"]) >= 1 # appears to be regional + results = yt.get_album("MPREb_BQZvl3BFGay") + assert len(results["tracks"]) == 7 + assert len(results["tracks"][0]["artists"]) == 1 + results = yt.get_album("MPREb_rqH94Zr3NN0") + assert len(results["tracks"][0]["artists"]) == 2 + + def test_get_song(self, config, yt, yt_oauth, sample_video): + song = yt_oauth.get_song(config["uploads"]["private_upload_id"]) # private upload + assert len(song) == 5 + song = yt.get_song(sample_video) + assert len(song["streamingData"]["adaptiveFormats"]) >= 10 + + def test_get_song_related_content(self, yt_oauth, sample_video): + song = yt_oauth.get_watch_playlist(sample_video) + song = yt_oauth.get_song_related(song["related"]) + assert len(song) >= 5 + + def test_get_lyrics(self, config, yt, sample_video): + playlist = yt.get_watch_playlist(sample_video) + lyrics_song = yt.get_lyrics(playlist["lyrics"]) + assert lyrics_song["lyrics"] is not None + assert lyrics_song["source"] is not None + + playlist = yt.get_watch_playlist(config["uploads"]["private_upload_id"]) + assert playlist["lyrics"] is None + with pytest.raises(Exception): + yt.get_lyrics(playlist["lyrics"]) + + def test_get_signatureTimestamp(self, yt): + signature_timestamp = yt.get_signatureTimestamp() + assert signature_timestamp is not None + + def test_set_tasteprofile(self, yt, yt_brand): + with pytest.raises(Exception): + yt.set_tasteprofile(["not an artist"]) + taste_profile = yt.get_tasteprofile() + assert yt.set_tasteprofile(list(taste_profile)[:5], taste_profile) is None + + with pytest.raises(Exception): + yt_brand.set_tasteprofile(["test", "test2"]) + taste_profile = yt_brand.get_tasteprofile() + assert yt_brand.set_tasteprofile(list(taste_profile)[:1], taste_profile) is None + + def test_get_tasteprofile(self, yt, yt_oauth): + result = yt.get_tasteprofile() + assert len(result) >= 0 + + result = yt_oauth.get_tasteprofile() + assert len(result) >= 0 + + def test_get_search_suggestions(self, yt, yt_brand, yt_auth): + result = yt.get_search_suggestions("fade") + assert len(result) >= 0 + + result = yt.get_search_suggestions("fade", detailed_runs=True) + assert len(result) >= 0 + + # add search term to history + first_pass = yt_brand.search("b") + assert len(first_pass) > 0 + + # get results + results = yt_auth.get_search_suggestions("b", detailed_runs=True) + assert len(results) > 0 + assert any(not item["fromHistory"] for item in results) diff --git a/tests/mixins/test_explore.py b/tests/mixins/test_explore.py new file mode 100644 index 00000000..1a7e8f57 --- /dev/null +++ b/tests/mixins/test_explore.py @@ -0,0 +1,17 @@ +class TestExplore: + def test_get_mood_playlists(self, yt): + categories = yt.get_mood_categories() + assert len(list(categories)) > 0 + cat = list(categories)[0] + assert len(categories[cat]) > 0 + playlists = yt.get_mood_playlists(categories[cat][0]["params"]) + assert len(playlists) > 0 + + def test_get_charts(self, yt, yt_oauth): + charts = yt_oauth.get_charts() + # songs section appears to be removed currently (US) + assert len(charts) >= 3 + charts = yt.get_charts(country="US") + assert len(charts) == 5 + charts = yt.get_charts(country="BE") + assert len(charts) == 4 diff --git a/tests/mixins/test_library.py b/tests/mixins/test_library.py new file mode 100644 index 00000000..3e236375 --- /dev/null +++ b/tests/mixins/test_library.py @@ -0,0 +1,105 @@ +import pytest + + +class TestLibrary: + def test_get_library_playlists(self, config, yt_oauth, yt_empty): + playlists = yt_oauth.get_library_playlists(50) + assert len(playlists) > 25 + + playlists = yt_oauth.get_library_playlists(None) + assert len(playlists) >= config.getint("limits", "library_playlists") + + playlists = yt_empty.get_library_playlists() + assert len(playlists) <= 1 # "Episodes saved for later" + + def test_get_library_songs(self, config, yt_oauth, yt_empty): + with pytest.raises(Exception): + yt_oauth.get_library_songs(None, True) + songs = yt_oauth.get_library_songs(100) + assert len(songs) >= 100 + songs = yt_oauth.get_library_songs(200, validate_responses=True) + assert len(songs) >= config.getint("limits", "library_songs") + songs = yt_oauth.get_library_songs(order="a_to_z") + assert len(songs) >= 25 + songs = yt_empty.get_library_songs() + assert len(songs) == 0 + + def test_get_library_albums(self, yt_oauth, yt_brand, yt_empty): + albums = yt_oauth.get_library_albums(100) + assert len(albums) > 50 + for album in albums: + assert "playlistId" in album + albums = yt_brand.get_library_albums(100, order="a_to_z") + assert len(albums) > 50 + albums = yt_brand.get_library_albums(100, order="z_to_a") + assert len(albums) > 50 + albums = yt_brand.get_library_albums(100, order="recently_added") + assert len(albums) > 50 + albums = yt_empty.get_library_albums() + assert len(albums) == 0 + + def test_get_library_artists(self, config, yt_auth, yt_oauth, yt_brand, yt_empty): + artists = yt_auth.get_library_artists(50) + assert len(artists) > 40 + artists = yt_oauth.get_library_artists(order="a_to_z", limit=50) + assert len(artists) > 40 + artists = yt_brand.get_library_artists(limit=None) + assert len(artists) > config.getint("limits", "library_artists") + artists = yt_empty.get_library_artists() + assert len(artists) == 0 + + def test_get_library_subscriptions(self, config, yt_brand, yt_empty): + artists = yt_brand.get_library_subscriptions(50) + assert len(artists) > 40 + artists = yt_brand.get_library_subscriptions(order="z_to_a") + assert len(artists) > 20 + artists = yt_brand.get_library_subscriptions(limit=None) + assert len(artists) > config.getint("limits", "library_subscriptions") + artists = yt_empty.get_library_subscriptions() + assert len(artists) == 0 + + def test_get_liked_songs(self, yt_brand, yt_empty): + songs = yt_brand.get_liked_songs(200) + assert len(songs["tracks"]) > 100 + songs = yt_empty.get_liked_songs() + assert songs["trackCount"] == 0 + + def test_get_history(self, yt_oauth): + songs = yt_oauth.get_history() + assert len(songs) > 0 + + def test_manipulate_history_items(self, yt_auth, sample_video): + song = yt_auth.get_song(sample_video) + response = yt_auth.add_history_item(song) + assert response.status_code == 204 + songs = yt_auth.get_history() + assert len(songs) > 0 + response = yt_auth.remove_history_items([songs[0]["feedbackToken"]]) + assert "feedbackResponses" in response + + def test_rate_song(self, yt_auth, sample_video): + response = yt_auth.rate_song(sample_video, "LIKE") + assert "actions" in response + response = yt_auth.rate_song(sample_video, "INDIFFERENT") + assert "actions" in response + + def test_edit_song_library_status(self, yt_brand, sample_album): + album = yt_brand.get_album(sample_album) + response = yt_brand.edit_song_library_status(album["tracks"][0]["feedbackTokens"]["add"]) + album = yt_brand.get_album(sample_album) + assert album["tracks"][0]["inLibrary"] + assert response["feedbackResponses"][0]["isProcessed"] + response = yt_brand.edit_song_library_status(album["tracks"][0]["feedbackTokens"]["remove"]) + album = yt_brand.get_album(sample_album) + assert not album["tracks"][0]["inLibrary"] + assert response["feedbackResponses"][0]["isProcessed"] + + def test_rate_playlist(self, yt_auth): + response = yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", "LIKE") + assert "actions" in response + response = yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", "INDIFFERENT") + assert "actions" in response + + def test_subscribe_artists(self, yt_auth): + yt_auth.subscribe_artists(["UCUDVBtnOQi4c7E8jebpjc9Q", "UCiMhD4jzUqG-IgPzUmmytRQ"]) + yt_auth.unsubscribe_artists(["UCUDVBtnOQi4c7E8jebpjc9Q", "UCiMhD4jzUqG-IgPzUmmytRQ"]) diff --git a/tests/mixins/test_playlists.py b/tests/mixins/test_playlists.py new file mode 100644 index 00000000..3ce433f9 --- /dev/null +++ b/tests/mixins/test_playlists.py @@ -0,0 +1,75 @@ +import time + +import pytest + + +class TestPlaylists: + def test_get_playlist_foreign(self, yt, yt_auth, yt_oauth): + with pytest.raises(Exception): + yt.get_playlist("PLABC") + playlist = yt_auth.get_playlist("PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", limit=300, suggestions_limit=7) + assert len(playlist["duration"]) > 5 + assert len(playlist["tracks"]) > 200 + assert "suggestions" not in playlist + + yt.get_playlist("RDATgXd-") + assert len(playlist["tracks"]) >= 100 + + playlist = yt_oauth.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 + assert len(playlist["suggestions"]) == 21 + assert len(playlist["related"]) == 10 + + def test_edit_playlist(self, config, yt_brand): + playlist = yt_brand.get_playlist(config["playlists"]["own"]) + response = yt_brand.edit_playlist( + playlist["id"], + title="", + description="", + privacyStatus="PRIVATE", + moveItem=( + playlist["tracks"][1]["setVideoId"], + playlist["tracks"][0]["setVideoId"], + ), + ) + assert response == "STATUS_SUCCEEDED", "Playlist edit failed" + yt_brand.edit_playlist( + playlist["id"], + title=playlist["title"], + description=playlist["description"], + privacyStatus=playlist["privacy"], + moveItem=( + playlist["tracks"][0]["setVideoId"], + playlist["tracks"][1]["setVideoId"], + ), + ) + assert response == "STATUS_SUCCEEDED", "Playlist edit failed" + + def test_end2end(self, config, yt_brand, sample_video): + playlist_id = yt_brand.create_playlist( + "test", + "test description", + source_playlist="OLAK5uy_lGQfnMNGvYCRdDq9ZLzJV2BJL2aHQsz9Y", + ) + assert len(playlist_id) == 34, "Playlist creation failed" + yt_brand.edit_playlist(playlist_id, addToTop=True) + response = yt_brand.add_playlist_items( + playlist_id, + [sample_video, sample_video], + source_playlist="OLAK5uy_nvjTE32aFYdFN7HCyMv3cGqD3wqBb4Jow", + duplicates=True, + ) + assert response["status"] == "STATUS_SUCCEEDED", "Adding playlist item failed" + assert len(response["playlistEditResults"]) > 0, "Adding playlist item failed" + time.sleep(3) + yt_brand.edit_playlist(playlist_id, addToTop=False) + playlist = yt_brand.get_playlist(playlist_id, related=True) + assert len(playlist["tracks"]) == 46, "Getting playlist items failed" + response = yt_brand.remove_playlist_items(playlist_id, playlist["tracks"]) + assert response == "STATUS_SUCCEEDED", "Playlist item removal failed" + yt_brand.delete_playlist(playlist_id) diff --git a/tests/mixins/test_search.py b/tests/mixins/test_search.py new file mode 100644 index 00000000..017af097 --- /dev/null +++ b/tests/mixins/test_search.py @@ -0,0 +1,90 @@ +import pytest + + +class TestSearch: + def test_search_exceptions(self): + query = "edm playlist" + with pytest.raises(Exception): + yt_auth.search(query, filter="song") + with pytest.raises(Exception): + yt_auth.search(query, scope="upload") + + @pytest.mark.parametrize("query", ["Monekes", "qllwlwl", "heun"]) + def test_search_queries(self, yt, yt_brand, query: str) -> None: + results = yt_brand.search(query) + assert ["resultType" in r for r in results] == [True] * len(results) + assert len(results) >= 10 + results = yt.search(query) + assert len(results) >= 10 + + def test_search_ignore_spelling(self, yt_auth): + results = yt_auth.search("Martin Stig Andersen - Deteriation", ignore_spelling=True) + assert len(results) > 0 + + def test_search_filters(self, yt_auth): + query = "hip hop playlist" + results = yt_auth.search(query, filter="songs") + assert len(results) > 10 + assert all(item["resultType"] == "song" for item in results) + results = yt_auth.search(query, filter="videos") + assert len(results) > 10 + assert all(item["resultType"] == "video" for item in results) + results = yt_auth.search(query, filter="albums", limit=40) + assert len(results) > 20 + assert all(item["resultType"] == "album" for item in results) + results = yt_auth.search("project-2", filter="artists", ignore_spelling=True) + assert len(results) > 10 + assert all(item["resultType"] == "artist" for item in results) + results = yt_auth.search("classical music", filter="playlists") + assert len(results) > 10 + assert all(item["resultType"] == "playlist" for item in results) + results = yt_auth.search("clasical music", filter="playlists", ignore_spelling=True) + assert len(results) > 10 + results = yt_auth.search("clasic rock", filter="community_playlists", ignore_spelling=True) + assert len(results) > 10 + assert all(item["resultType"] == "playlist" for item in results) + results = yt_auth.search("hip hop", filter="featured_playlists") + assert len(results) > 10 + assert all(item["resultType"] == "playlist" for item in results) + results = yt_auth.search("some user", filter="profiles") + assert len(results) > 10 + assert all(item["resultType"] == "profile" for item in results) + results = yt_auth.search(query, filter="podcasts") + assert len(results) > 10 + assert all(item["resultType"] == "podcast" for item in results) + results = yt_auth.search(query, filter="episodes") + assert len(results) > 10 + assert all(item["resultType"] == "episode" for item in results) + + def test_search_uploads(self, config, yt, yt_oauth): + with pytest.raises(Exception, match="No filter can be set when searching uploads"): + yt.search( + config["queries"]["uploads_songs"], + filter="songs", + scope="uploads", + limit=40, + ) + results = yt_oauth.search(config["queries"]["uploads_songs"], scope="uploads", limit=40) + assert len(results) > 20 + + def test_search_library(self, config, yt_oauth): + results = yt_oauth.search(config["queries"]["library_any"], scope="library") + assert len(results) > 5 + results = yt_oauth.search( + config["queries"]["library_songs"], filter="songs", scope="library", limit=40 + ) + assert len(results) > 10 + results = yt_oauth.search( + config["queries"]["library_albums"], filter="albums", scope="library", limit=40 + ) + assert len(results) >= 4 + results = yt_oauth.search( + config["queries"]["library_artists"], filter="artists", scope="library", limit=40 + ) + assert len(results) >= 1 + results = yt_oauth.search(config["queries"]["library_playlists"], filter="playlists", scope="library") + assert len(results) >= 1 + with pytest.raises(Exception): + yt_oauth.search("beatles", filter="community_playlists", scope="library", limit=40) + with pytest.raises(Exception): + yt_oauth.search("beatles", filter="featured_playlists", scope="library", limit=40) diff --git a/tests/mixins/test_uploads.py b/tests/mixins/test_uploads.py new file mode 100644 index 00000000..c37e6e02 --- /dev/null +++ b/tests/mixins/test_uploads.py @@ -0,0 +1,62 @@ +import tempfile + +import pytest + +from tests.conftest import get_resource + + +class TestUploads: + def test_get_library_upload_songs(self, yt_oauth, yt_empty): + results = yt_oauth.get_library_upload_songs(50, order="z_to_a") + assert len(results) > 25 + + results = yt_empty.get_library_upload_songs(100) + assert len(results) == 0 + + def test_get_library_upload_albums(self, config, yt_oauth, yt_empty): + results = yt_oauth.get_library_upload_albums(50, order="a_to_z") + assert len(results) > 40 + + albums = yt_oauth.get_library_upload_albums(None) + assert len(albums) >= config.getint("limits", "library_upload_albums") + + results = yt_empty.get_library_upload_albums(100) + assert len(results) == 0 + + def test_get_library_upload_artists(self, config, yt_oauth, yt_empty): + artists = yt_oauth.get_library_upload_artists(None) + assert len(artists) >= config.getint("limits", "library_upload_artists") + + results = yt_oauth.get_library_upload_artists(50, order="recently_added") + assert len(results) >= 25 + + results = yt_empty.get_library_upload_artists(100) + assert len(results) == 0 + + def test_upload_song_exceptions(self, config, yt_auth, yt_oauth): + with pytest.raises(Exception, match="The provided file does not exist."): + yt_auth.upload_song("song.wav") + with tempfile.NamedTemporaryFile(suffix="wav") as temp, pytest.raises( + Exception, match="The provided file type is not supported" + ): + yt_auth.upload_song(temp.name) + with pytest.raises(Exception, match="Please provide browser authentication"): + yt_oauth.upload_song(config["uploads"]["file"]) + + def test_upload_song(self, config, yt_auth): + response = yt_auth.upload_song(get_resource(config["uploads"]["file"])) + assert response.status_code == 409 + + @pytest.mark.skip(reason="Do not delete uploads") + def test_delete_upload_entity(self, yt_oauth): + results = yt_oauth.get_library_upload_songs() + response = yt_oauth.delete_upload_entity(results[0]["entityId"]) + assert response == "STATUS_SUCCEEDED" + + def test_get_library_upload_album(self, config, yt_oauth): + album = yt_oauth.get_library_upload_album(config["uploads"]["private_album_id"]) + assert len(album["tracks"]) > 0 + + def test_get_library_upload_artist(self, config, yt_oauth): + tracks = yt_oauth.get_library_upload_artist(config["uploads"]["private_artist_id"], 100) + assert len(tracks) > 0 diff --git a/tests/mixins/test_watch.py b/tests/mixins/test_watch.py new file mode 100644 index 00000000..0262274a --- /dev/null +++ b/tests/mixins/test_watch.py @@ -0,0 +1,16 @@ +class TestWatch: + def test_get_watch_playlist(self, config, yt, yt_brand, yt_oauth): + playlist = yt_oauth.get_watch_playlist( + playlistId="RDAMPLOLAK5uy_l_fKDQGOUsk8kbWsm9s86n4-nZNd2JR8Q", + radio=True, + limit=90, + ) + assert len(playlist["tracks"]) >= 90 + playlist = yt_oauth.get_watch_playlist("9mWr4c_ig54", limit=50) + assert len(playlist["tracks"]) > 45 + playlist = yt_oauth.get_watch_playlist("UoAf_y9Ok4k") # private track + assert len(playlist["tracks"]) >= 25 + playlist = yt.get_watch_playlist(playlistId=config["albums"]["album_browse_id"], shuffle=True) + assert len(playlist["tracks"]) == config.getint("albums", "album_track_length") + playlist = yt_brand.get_watch_playlist(playlistId=config["playlists"]["own"], shuffle=True) + assert len(playlist["tracks"]) == config.getint("playlists", "own_length") diff --git a/tests/test.py b/tests/test.py deleted file mode 100644 index f25836b4..00000000 --- a/tests/test.py +++ /dev/null @@ -1,610 +0,0 @@ -import configparser -import json -import time -import unittest -import warnings -from pathlib import Path -from unittest import mock - -from requests import Response - -from ytmusicapi.auth.types import AuthType -from ytmusicapi.constants import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET -from ytmusicapi.setup import main, setup # noqa: E402 -from ytmusicapi.ytmusic import OAuthCredentials, YTMusic # noqa: E402 - - -def get_resource(file: str) -> str: - data_dir = Path(__file__).parent - return data_dir.joinpath(file).as_posix() - - -config = configparser.RawConfigParser() -config.read(get_resource("test.cfg"), "utf-8") - -sample_album = "MPREb_4pL8gzRtw1p" # Eminem - Revival -sample_video = "hpSrLjc5SMs" # Oasis - Wonderwall -sample_playlist = "PL6bPxvf5dW5clc3y9wAoslzqUrmkZ5c-u" # very large playlist -blank_code = { - "device_code": "", - "user_code": "", - "expires_in": 1800, - "interval": 5, - "verification_url": "https://www.google.com/device", -} - -oauth_filepath = get_resource(config["auth"]["oauth_file"]) -browser_filepath = get_resource(config["auth"]["browser_file"]) - -alt_oauth_creds = OAuthCredentials(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) - - -class TestYTMusic(unittest.TestCase): - @classmethod - def setUpClass(cls): - warnings.filterwarnings(action="ignore", message="unclosed", category=ResourceWarning) - with YTMusic(requests_session=False) as yt: - assert isinstance(yt, YTMusic) - cls.yt = YTMusic() - cls.yt_oauth = YTMusic(oauth_filepath) - cls.yt_alt_oauth = YTMusic(browser_filepath, oauth_credentials=alt_oauth_creds) - cls.yt_auth = YTMusic(browser_filepath, location="GB") - cls.yt_brand = YTMusic(config["auth"]["headers"], config["auth"]["brand_account"]) - cls.yt_empty = YTMusic(config["auth"]["headers_empty"], config["auth"]["brand_account_empty"]) - - @mock.patch("sys.argv", ["ytmusicapi", "browser", "--file", browser_filepath]) - def test_setup_browser(self): - headers = setup(browser_filepath, config["auth"]["headers_raw"]) - self.assertGreaterEqual(len(headers), 2) - headers_raw = config["auth"]["headers_raw"].split("\n") - with mock.patch("builtins.input", side_effect=(headers_raw + [EOFError()])): - headers = main() - self.assertGreaterEqual(len(headers), 2) - - @mock.patch("requests.Response.json") - @mock.patch("requests.Session.post") - @mock.patch("sys.argv", ["ytmusicapi", "oauth", "--file", oauth_filepath]) - def test_setup_oauth(self, session_mock, json_mock): - session_mock.return_value = Response() - fresh_token = self.yt_oauth._token.as_dict() - json_mock.side_effect = [blank_code, fresh_token] - with mock.patch("builtins.input", return_value="y"): - main() - self.assertTrue(Path(oauth_filepath).exists()) - - json_mock.side_effect = None - with open(oauth_filepath, mode="r", encoding="utf8") as oauth_file: - string_oauth_token = oauth_file.read() - self.yt_oauth = YTMusic(string_oauth_token) - - ############### - # OAUTH - ############### - def test_oauth_tokens(self): - # ensure instance initialized token - self.assertIsNotNone(self.yt_oauth._token) - - # set reference file - with open(oauth_filepath, "r") as f: - first_json = json.load(f) - - # pull reference values from underlying token - first_token = self.yt_oauth._token.access_token - first_expire = self.yt_oauth._token.expires_at - # make token expire - self.yt_oauth._token.expires_at = time.time() - # check - self.assertTrue(self.yt_oauth._token.is_expiring) - # pull new values, assuming token will be refreshed on access - second_token = self.yt_oauth._token.access_token - second_expire = self.yt_oauth._token.expires_at - second_token_inner = self.yt_oauth._token.access_token - # check it was refreshed - self.assertNotEqual(first_token, second_token) - # check expiration timestamps to confirm - self.assertNotEqual(second_expire, first_expire) - self.assertGreater(second_expire, time.time() + 60) - # check token is propagating properly - self.assertEqual(second_token, second_token_inner) - - with open(oauth_filepath, "r") as f2: - second_json = json.load(f2) - - # ensure token is updating local file - self.assertNotEqual(first_json, second_json) - - def test_oauth_custom_client(self): - # ensure client works/ignores alt if browser credentials passed as auth - self.assertNotEqual(self.yt_alt_oauth.auth_type, AuthType.OAUTH_CUSTOM_CLIENT) - with open(oauth_filepath, "r") as f: - token_dict = json.load(f) - # oauth token dict entry and alt - self.yt_alt_oauth = YTMusic(token_dict, oauth_credentials=alt_oauth_creds) - self.assertEqual(self.yt_alt_oauth.auth_type, AuthType.OAUTH_CUSTOM_CLIENT) - - ############### - # BROWSING - ############### - - def test_get_home(self): - result = self.yt.get_home(limit=6) - self.assertGreaterEqual(len(result), 6) - result = self.yt_auth.get_home(limit=15) - self.assertGreaterEqual(len(result), 15) - - def test_search(self): - query = "edm playlist" - self.assertRaises(Exception, self.yt_auth.search, query, filter="song") - self.assertRaises(Exception, self.yt_auth.search, query, scope="upload") - queries = ["Monekes", "qllwlwl", "heun"] - for q in queries: - with self.subTest(): - results = self.yt_brand.search(q) - self.assertListEqual(["resultType" in r for r in results], [True] * len(results)) - self.assertGreaterEqual(len(results), 10) - results = self.yt.search(q) - self.assertGreaterEqual(len(results), 10) - results = self.yt_auth.search("Martin Stig Andersen - Deteriation", ignore_spelling=True) - self.assertGreater(len(results), 0) - - def test_search_filters(self): - query = "hip hop playlist" - results = self.yt_auth.search(query, filter="songs") - self.assertGreater(len(results), 10) - self.assertTrue(all(item["resultType"] == "song" for item in results)) - results = self.yt_auth.search(query, filter="videos") - self.assertGreater(len(results), 10) - self.assertTrue(all(item["resultType"] == "video" for item in results)) - results = self.yt_auth.search(query, filter="albums", limit=40) - self.assertGreater(len(results), 20) - self.assertTrue(all(item["resultType"] == "album" for item in results)) - results = self.yt_auth.search("project-2", filter="artists", ignore_spelling=True) - self.assertGreater(len(results), 10) - self.assertTrue(all(item["resultType"] == "artist" for item in results)) - results = self.yt_auth.search("classical music", filter="playlists") - self.assertGreater(len(results), 10) - self.assertTrue(all(item["resultType"] == "playlist" for item in results)) - results = self.yt_auth.search("clasical music", filter="playlists", ignore_spelling=True) - self.assertGreater(len(results), 10) - results = self.yt_auth.search("clasic rock", filter="community_playlists", ignore_spelling=True) - self.assertGreater(len(results), 10) - self.assertTrue(all(item["resultType"] == "playlist" for item in results)) - results = self.yt_auth.search("hip hop", filter="featured_playlists") - self.assertGreater(len(results), 10) - self.assertTrue(all(item["resultType"] == "playlist" for item in results)) - results = self.yt_auth.search("some user", filter="profiles") - self.assertGreater(len(results), 10) - self.assertTrue(all(item["resultType"] == "profile" for item in results)) - results = self.yt_auth.search(query, filter="podcasts") - self.assertGreater(len(results), 10) - self.assertTrue(all(item["resultType"] == "podcast" for item in results)) - results = self.yt_auth.search(query, filter="episodes") - self.assertGreater(len(results), 10) - self.assertTrue(all(item["resultType"] == "episode" for item in results)) - - def test_search_uploads(self): - self.assertRaises( - Exception, - self.yt.search, - config["queries"]["uploads_songs"], - filter="songs", - scope="uploads", - limit=40, - ) - results = self.yt_auth.search(config["queries"]["uploads_songs"], scope="uploads", limit=40) - self.assertGreater(len(results), 20) - - def test_search_library(self): - results = self.yt_oauth.search(config["queries"]["library_any"], scope="library") - self.assertGreater(len(results), 5) - results = self.yt_alt_oauth.search( - config["queries"]["library_songs"], filter="songs", scope="library", limit=40 - ) - self.assertGreater(len(results), 10) - results = self.yt_auth.search( - config["queries"]["library_albums"], filter="albums", scope="library", limit=40 - ) - self.assertGreaterEqual(len(results), 4) - results = self.yt_auth.search( - config["queries"]["library_artists"], filter="artists", scope="library", limit=40 - ) - self.assertGreaterEqual(len(results), 1) - results = self.yt_auth.search( - config["queries"]["library_playlists"], filter="playlists", scope="library" - ) - self.assertGreaterEqual(len(results), 1) - self.assertRaises( - Exception, self.yt_auth.search, "beatles", filter="community_playlists", scope="library", limit=40 - ) - self.assertRaises( - Exception, self.yt_auth.search, "beatles", filter="featured_playlists", scope="library", limit=40 - ) - - def test_get_artist(self): - results = self.yt.get_artist("MPLAUCmMUZbaYdNH0bEd1PAlAqsA") - self.assertEqual(len(results), 14) - - # test correctness of related artists - related = results["related"]["results"] - self.assertEqual( - len([x for x in related if set(x.keys()) == {"browseId", "subscribers", "title", "thumbnails"}]), - len(related), - ) - - results = self.yt.get_artist("UCLZ7tlKC06ResyDmEStSrOw") # no album year - self.assertGreaterEqual(len(results), 11) - - def test_get_artist_albums(self): - artist = self.yt.get_artist("UCj5ZiBBqpe0Tg4zfKGHEFuQ") - results = self.yt.get_artist_albums(artist["albums"]["browseId"], artist["albums"]["params"]) - self.assertGreater(len(results), 0) - - def test_get_artist_singles(self): - artist = self.yt.get_artist("UCAeLFBCQS7FvI8PvBrWvSBg") - results = self.yt.get_artist_albums(artist["singles"]["browseId"], artist["singles"]["params"]) - self.assertGreater(len(results), 0) - - def test_get_user(self): - results = self.yt.get_user("UC44hbeRoCZVVMVg5z0FfIww") - self.assertEqual(len(results), 3) - - def test_get_user_playlists(self): - results = self.yt.get_user("UCPVhZsC2od1xjGhgEc2NEPQ") - results = self.yt.get_user_playlists("UCPVhZsC2od1xjGhgEc2NEPQ", results["playlists"]["params"]) - self.assertGreater(len(results), 100) - - def test_get_album_browse_id(self): - warnings.filterwarnings(action="ignore", category=DeprecationWarning) - browse_id = self.yt.get_album_browse_id("OLAK5uy_nMr9h2VlS-2PULNz3M3XVXQj_P3C2bqaY") - self.assertEqual(browse_id, sample_album) - with self.subTest(): - escaped_browse_id = self.yt.get_album_browse_id("OLAK5uy_nbMYyrfeg5ZgknoOsOGBL268hGxtcbnDM") - self.assertEqual(escaped_browse_id, "MPREb_scJdtUCpPE2") - - def test_get_album(self): - results = self.yt_auth.get_album(sample_album) - self.assertGreaterEqual(len(results), 9) - self.assertTrue(results["tracks"][0]["isExplicit"]) - self.assertTrue(all(item["views"] is not None for item in results["tracks"])) - self.assertTrue(all(item["album"] is not None for item in results["tracks"])) - self.assertIn("feedbackTokens", results["tracks"][0]) - self.assertGreaterEqual(len(results["other_versions"]), 1) # appears to be regional - results = self.yt.get_album("MPREb_BQZvl3BFGay") - self.assertEqual(len(results["tracks"]), 7) - self.assertEqual(len(results["tracks"][0]["artists"]), 1) - results = self.yt.get_album("MPREb_rqH94Zr3NN0") - self.assertEqual(len(results["tracks"][0]["artists"]), 2) - - def test_get_song(self): - song = self.yt_oauth.get_song(config["uploads"]["private_upload_id"]) # private upload - self.assertEqual(len(song), 5) - song = self.yt.get_song(sample_video) - self.assertGreaterEqual(len(song["streamingData"]["adaptiveFormats"]), 10) - - def test_get_song_related_content(self): - song = self.yt_oauth.get_watch_playlist(sample_video) - song = self.yt_alt_oauth.get_song_related(song["related"]) - self.assertGreaterEqual(len(song), 5) - - def test_get_lyrics(self): - playlist = self.yt.get_watch_playlist(sample_video) - lyrics_song = self.yt.get_lyrics(playlist["lyrics"]) - self.assertIsNotNone(lyrics_song["lyrics"]) - self.assertIsNotNone(lyrics_song["source"]) - - playlist = self.yt.get_watch_playlist(config["uploads"]["private_upload_id"]) - self.assertIsNone(playlist["lyrics"]) - self.assertRaises(Exception, self.yt.get_lyrics, playlist["lyrics"]) - - def test_get_signatureTimestamp(self): - signature_timestamp = self.yt.get_signatureTimestamp() - self.assertIsNotNone(signature_timestamp) - - def test_set_tasteprofile(self): - self.assertRaises(Exception, self.yt.set_tasteprofile, "not an artist") - taste_profile = self.yt.get_tasteprofile() - self.assertIsNone(self.yt.set_tasteprofile(list(taste_profile)[:5], taste_profile)) - - self.assertRaises(Exception, self.yt_brand.set_tasteprofile, ["test", "test2"]) - taste_profile = self.yt_brand.get_tasteprofile() - self.assertIsNone(self.yt_brand.set_tasteprofile(list(taste_profile)[:1], taste_profile)) - - def test_get_tasteprofile(self): - result = self.yt.get_tasteprofile() - self.assertGreaterEqual(len(result), 0) - - result = self.yt_oauth.get_tasteprofile() - self.assertGreaterEqual(len(result), 0) - - def test_get_search_suggestions(self): - result = self.yt.get_search_suggestions("fade") - self.assertGreaterEqual(len(result), 0) - - result = self.yt.get_search_suggestions("fade", detailed_runs=True) - self.assertGreaterEqual(len(result), 0) - - # add search term to history - first_pass = self.yt_brand.search("b") - self.assertGreater(len(first_pass), 0) - # get results - results = self.yt_auth.get_search_suggestions("b", detailed_runs=True) - self.assertGreater(len(results), 0) - self.assertTrue(any(not item["fromHistory"] for item in results)) - - ################ - # EXPLORE - ################ - - def test_get_mood_playlists(self): - categories = self.yt.get_mood_categories() - self.assertGreater(len(list(categories)), 0) - cat = list(categories)[0] - self.assertGreater(len(categories[cat]), 0) - playlists = self.yt.get_mood_playlists(categories[cat][0]["params"]) - self.assertGreater(len(playlists), 0) - - def test_get_charts(self): - charts = self.yt_oauth.get_charts() - # songs section appears to be removed currently (US) - self.assertGreaterEqual(len(charts), 3) - charts = self.yt.get_charts(country="US") - self.assertEqual(len(charts), 5) - charts = self.yt.get_charts(country="BE") - self.assertEqual(len(charts), 4) - - ############### - # WATCH - ############### - - def test_get_watch_playlist(self): - playlist = self.yt_oauth.get_watch_playlist( - playlistId="RDAMPLOLAK5uy_l_fKDQGOUsk8kbWsm9s86n4-nZNd2JR8Q", - radio=True, - limit=90, - ) - self.assertGreaterEqual(len(playlist["tracks"]), 90) - playlist = self.yt_oauth.get_watch_playlist("9mWr4c_ig54", limit=50) - self.assertGreater(len(playlist["tracks"]), 45) - playlist = self.yt_oauth.get_watch_playlist("UoAf_y9Ok4k") # private track - self.assertGreaterEqual(len(playlist["tracks"]), 25) - playlist = self.yt.get_watch_playlist(playlistId=config["albums"]["album_browse_id"], shuffle=True) - self.assertEqual(len(playlist["tracks"]), config.getint("albums", "album_track_length")) - playlist = self.yt_brand.get_watch_playlist(playlistId=config["playlists"]["own"], shuffle=True) - self.assertEqual(len(playlist["tracks"]), config.getint("playlists", "own_length")) - - ################ - # LIBRARY - ################ - - def test_get_library_playlists(self): - playlists = self.yt_oauth.get_library_playlists(50) - self.assertGreater(len(playlists), 25) - - playlists = self.yt_auth.get_library_playlists(None) - self.assertGreaterEqual(len(playlists), config.getint("limits", "library_playlists")) - - playlists = self.yt_empty.get_library_playlists() - self.assertLessEqual(len(playlists), 1) # "Episodes saved for later" - - def test_get_library_songs(self): - self.assertRaises(Exception, self.yt_auth.get_library_songs, None, True) - songs = self.yt_oauth.get_library_songs(100) - self.assertGreaterEqual(len(songs), 100) - songs = self.yt_auth.get_library_songs(200, validate_responses=True) - self.assertGreaterEqual(len(songs), config.getint("limits", "library_songs")) - songs = self.yt_auth.get_library_songs(order="a_to_z") - self.assertGreaterEqual(len(songs), 25) - songs = self.yt_empty.get_library_songs() - self.assertEqual(len(songs), 0) - - def test_get_library_albums(self): - albums = self.yt_oauth.get_library_albums(100) - self.assertGreater(len(albums), 50) - for album in albums: - self.assertIn("playlistId", album) - albums = self.yt_brand.get_library_albums(100, order="a_to_z") - self.assertGreater(len(albums), 50) - albums = self.yt_brand.get_library_albums(100, order="z_to_a") - self.assertGreater(len(albums), 50) - albums = self.yt_brand.get_library_albums(100, order="recently_added") - self.assertGreater(len(albums), 50) - albums = self.yt_empty.get_library_albums() - self.assertEqual(len(albums), 0) - - def test_get_library_artists(self): - artists = self.yt_auth.get_library_artists(50) - self.assertGreater(len(artists), 40) - artists = self.yt_oauth.get_library_artists(order="a_to_z", limit=50) - self.assertGreater(len(artists), 40) - artists = self.yt_brand.get_library_artists(limit=None) - self.assertGreater(len(artists), config.getint("limits", "library_artists")) - artists = self.yt_empty.get_library_artists() - self.assertEqual(len(artists), 0) - - def test_get_library_subscriptions(self): - artists = self.yt_brand.get_library_subscriptions(50) - self.assertGreater(len(artists), 40) - artists = self.yt_brand.get_library_subscriptions(order="z_to_a") - self.assertGreater(len(artists), 20) - artists = self.yt_brand.get_library_subscriptions(limit=None) - self.assertGreater(len(artists), config.getint("limits", "library_subscriptions")) - artists = self.yt_empty.get_library_subscriptions() - self.assertEqual(len(artists), 0) - - def test_get_liked_songs(self): - songs = self.yt_brand.get_liked_songs(200) - self.assertGreater(len(songs["tracks"]), 100) - songs = self.yt_empty.get_liked_songs() - self.assertEqual(songs["trackCount"], 0) - - def test_get_history(self): - songs = self.yt_oauth.get_history() - self.assertGreater(len(songs), 0) - - def test_manipulate_history_items(self): - song = self.yt_auth.get_song(sample_video) - response = self.yt_auth.add_history_item(song) - self.assertEqual(response.status_code, 204) - songs = self.yt_auth.get_history() - self.assertGreater(len(songs), 0) - response = self.yt_auth.remove_history_items([songs[0]["feedbackToken"]]) - self.assertIn("feedbackResponses", response) - - def test_rate_song(self): - response = self.yt_auth.rate_song(sample_video, "LIKE") - self.assertIn("actions", response) - response = self.yt_auth.rate_song(sample_video, "INDIFFERENT") - self.assertIn("actions", response) - - def test_edit_song_library_status(self): - album = self.yt_brand.get_album(sample_album) - response = self.yt_brand.edit_song_library_status(album["tracks"][0]["feedbackTokens"]["add"]) - album = self.yt_brand.get_album(sample_album) - self.assertTrue(album["tracks"][0]["inLibrary"]) - self.assertTrue(response["feedbackResponses"][0]["isProcessed"]) - response = self.yt_brand.edit_song_library_status(album["tracks"][0]["feedbackTokens"]["remove"]) - album = self.yt_brand.get_album(sample_album) - self.assertFalse(album["tracks"][0]["inLibrary"]) - self.assertTrue(response["feedbackResponses"][0]["isProcessed"]) - - def test_rate_playlist(self): - response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", "LIKE") - self.assertIn("actions", response) - response = self.yt_auth.rate_playlist("OLAK5uy_l3g4WcHZsEx_QuEDZzWEiyFzZl6pL0xZ4", "INDIFFERENT") - self.assertIn("actions", response) - - def test_subscribe_artists(self): - self.yt_auth.subscribe_artists(["UCUDVBtnOQi4c7E8jebpjc9Q", "UCiMhD4jzUqG-IgPzUmmytRQ"]) - self.yt_auth.unsubscribe_artists(["UCUDVBtnOQi4c7E8jebpjc9Q", "UCiMhD4jzUqG-IgPzUmmytRQ"]) - - ############### - # PLAYLISTS - ############### - - def test_get_playlist_foreign(self): - self.assertRaises(Exception, self.yt.get_playlist, "PLABC") - playlist = self.yt_auth.get_playlist( - "PLk5BdzXBUiUe8Q5I13ZSCD8HbxMqJUUQA", limit=300, suggestions_limit=7 - ) - self.assertGreater(len(playlist["duration"]), 5) - self.assertGreater(len(playlist["tracks"]), 200) - self.assertNotIn("suggestions", playlist) - - self.yt.get_playlist("RDATgXd-") - self.assertGreaterEqual(len(playlist["tracks"]), 100) - - playlist = self.yt_oauth.get_playlist("PLj4BSJLnVpNyIjbCWXWNAmybc97FXLlTk", limit=None, related=True) - self.assertGreater(len(playlist["tracks"]), 200) - self.assertEqual(len(playlist["related"]), 0) - - def test_get_playlist_owned(self): - playlist = self.yt_brand.get_playlist(config["playlists"]["own"], related=True, suggestions_limit=21) - self.assertLess(len(playlist["tracks"]), 100) - self.assertEqual(len(playlist["suggestions"]), 21) - self.assertEqual(len(playlist["related"]), 10) - - def test_edit_playlist(self): - playlist = self.yt_brand.get_playlist(config["playlists"]["own"]) - response = self.yt_brand.edit_playlist( - playlist["id"], - title="", - description="", - privacyStatus="PRIVATE", - moveItem=( - playlist["tracks"][1]["setVideoId"], - playlist["tracks"][0]["setVideoId"], - ), - ) - self.assertEqual(response, "STATUS_SUCCEEDED", "Playlist edit failed") - self.yt_brand.edit_playlist( - playlist["id"], - title=playlist["title"], - description=playlist["description"], - privacyStatus=playlist["privacy"], - moveItem=( - playlist["tracks"][0]["setVideoId"], - playlist["tracks"][1]["setVideoId"], - ), - ) - self.assertEqual(response, "STATUS_SUCCEEDED", "Playlist edit failed") - - # end-to-end test adding playlist, adding item, deleting item, deleting playlist - # @unittest.skip('You are creating too many playlists. Please wait a bit...') - def test_end2end(self): - playlist_id = self.yt_brand.create_playlist( - "test", - "test description", - source_playlist="OLAK5uy_lGQfnMNGvYCRdDq9ZLzJV2BJL2aHQsz9Y", - ) - self.assertEqual(len(playlist_id), 34, "Playlist creation failed") - self.yt_brand.edit_playlist(playlist_id, addToTop=True) - response = self.yt_brand.add_playlist_items( - playlist_id, - [sample_video, sample_video], - source_playlist="OLAK5uy_nvjTE32aFYdFN7HCyMv3cGqD3wqBb4Jow", - duplicates=True, - ) - self.assertEqual(response["status"], "STATUS_SUCCEEDED", "Adding playlist item failed") - self.assertGreater(len(response["playlistEditResults"]), 0, "Adding playlist item failed") - time.sleep(2) - self.yt_brand.edit_playlist(playlist_id, addToTop=False) - playlist = self.yt_brand.get_playlist(playlist_id, related=True) - self.assertEqual(len(playlist["tracks"]), 46, "Getting playlist items failed") - response = self.yt_brand.remove_playlist_items(playlist_id, playlist["tracks"]) - self.assertEqual(response, "STATUS_SUCCEEDED", "Playlist item removal failed") - self.yt_brand.delete_playlist(playlist_id) - - ############### - # UPLOADS - ############### - - def test_get_library_upload_songs(self): - results = self.yt_oauth.get_library_upload_songs(50, order="z_to_a") - self.assertGreater(len(results), 25) - - results = self.yt_empty.get_library_upload_songs(100) - self.assertEqual(len(results), 0) - - def test_get_library_upload_albums(self): - results = self.yt_oauth.get_library_upload_albums(50, order="a_to_z") - self.assertGreater(len(results), 40) - - albums = self.yt_auth.get_library_upload_albums(None) - self.assertGreaterEqual(len(albums), config.getint("limits", "library_upload_albums")) - - results = self.yt_empty.get_library_upload_albums(100) - self.assertEqual(len(results), 0) - - def test_get_library_upload_artists(self): - artists = self.yt_oauth.get_library_upload_artists(None) - self.assertGreaterEqual(len(artists), config.getint("limits", "library_upload_artists")) - - results = self.yt_auth.get_library_upload_artists(50, order="recently_added") - self.assertGreaterEqual(len(results), 25) - - results = self.yt_empty.get_library_upload_artists(100) - self.assertEqual(len(results), 0) - - def test_upload_song(self): - self.assertRaises(Exception, self.yt_auth.upload_song, "song.wav") - self.assertRaises(Exception, self.yt_oauth.upload_song, config["uploads"]["file"]) - response = self.yt_auth.upload_song(get_resource(config["uploads"]["file"])) - self.assertEqual(response.status_code, 409) - - @unittest.skip("Do not delete uploads") - def test_delete_upload_entity(self): - results = self.yt_oauth.get_library_upload_songs() - response = self.yt_oauth.delete_upload_entity(results[0]["entityId"]) - self.assertEqual(response, "STATUS_SUCCEEDED") - - def test_get_library_upload_album(self): - album = self.yt_oauth.get_library_upload_album(config["uploads"]["private_album_id"]) - self.assertGreater(len(album["tracks"]), 0) - - def test_get_library_upload_artist(self): - tracks = self.yt_oauth.get_library_upload_artist(config["uploads"]["private_artist_id"], 100) - self.assertGreater(len(tracks), 0) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_ytmusic.py b/tests/test_ytmusic.py new file mode 100644 index 00000000..63ae9544 --- /dev/null +++ b/tests/test_ytmusic.py @@ -0,0 +1,6 @@ +from ytmusicapi import YTMusic + + +def test_ytmusic_context(): + with YTMusic(requests_session=False) as yt: + assert isinstance(yt, YTMusic) From d4bea9341433f893826cf668f1eed117acd88542 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 2 Jan 2024 22:25:45 +0100 Subject: [PATCH 235/238] improve coverage --- .gitignore | 2 ++ tests/__init__.py | 15 --------------- tests/mixins/test_browsing.py | 10 +++++++--- tests/mixins/test_search.py | 6 +++--- ytmusicapi/mixins/_protocol.py | 8 ++++---- ytmusicapi/navigation.py | 4 ++-- 6 files changed, 18 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 318a0ce8..210086e0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ build dist .pdm-python .venv +*.log +*.xml diff --git a/tests/__init__.py b/tests/__init__.py index 4f3ce510..e69de29b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,15 +0,0 @@ -from importlib.metadata import PackageNotFoundError, version - -from ytmusicapi.setup import setup, setup_oauth -from ytmusicapi.ytmusic import YTMusic - -try: - __version__ = version("ytmusicapi") -except PackageNotFoundError: - # package is not installed - pass - -__copyright__ = "Copyright 2023 sigma67" -__license__ = "MIT" -__title__ = "ytmusicapi" -__all__ = ["YTMusic", "setup_oauth", "setup"] diff --git a/tests/mixins/test_browsing.py b/tests/mixins/test_browsing.py index fc8677f8..f626d50c 100644 --- a/tests/mixins/test_browsing.py +++ b/tests/mixins/test_browsing.py @@ -37,11 +37,15 @@ def test_get_user(self, yt): results = yt.get_user("UC44hbeRoCZVVMVg5z0FfIww") assert len(results) == 3 - def test_get_user_playlists(self, yt_auth): - results = yt_auth.get_user("UCPVhZsC2od1xjGhgEc2NEPQ") # Vevo playlists - results = yt_auth.get_user_playlists("UCPVhZsC2od1xjGhgEc2NEPQ", results["playlists"]["params"]) + def test_get_user_playlists(self, yt, yt_auth): + channel = "UCPVhZsC2od1xjGhgEc2NEPQ" # Vevo playlists + user = yt_auth.get_user(channel) + results = yt_auth.get_user_playlists(channel, user["playlists"]["params"]) assert len(results) > 100 + results_empty = yt.get_user_playlists(channel, user["playlists"]["params"]) + assert len(results_empty) == 0 + def test_get_album_browse_id(self, yt, sample_album): warnings.filterwarnings(action="ignore", category=DeprecationWarning) browse_id = yt.get_album_browse_id("OLAK5uy_nMr9h2VlS-2PULNz3M3XVXQj_P3C2bqaY") diff --git a/tests/mixins/test_search.py b/tests/mixins/test_search.py index 017af097..7750c851 100644 --- a/tests/mixins/test_search.py +++ b/tests/mixins/test_search.py @@ -2,11 +2,11 @@ class TestSearch: - def test_search_exceptions(self): + def test_search_exceptions(self, yt_auth): query = "edm playlist" - with pytest.raises(Exception): + with pytest.raises(Exception, match="Invalid filter provided"): yt_auth.search(query, filter="song") - with pytest.raises(Exception): + with pytest.raises(Exception, match="Invalid scope provided"): yt_auth.search(query, scope="upload") @pytest.mark.parametrize("query", ["Monekes", "qllwlwl", "heun"]) diff --git a/ytmusicapi/mixins/_protocol.py b/ytmusicapi/mixins/_protocol.py index 2983b5d0..40fe733d 100644 --- a/ytmusicapi/mixins/_protocol.py +++ b/ytmusicapi/mixins/_protocol.py @@ -17,14 +17,14 @@ class MixinProtocol(Protocol): proxies: Optional[Dict[str, str]] def _check_auth(self) -> None: - pass + """checks if self has authentication""" def _send_request(self, endpoint: str, body: Dict, additionalParams: str = "") -> Dict: - pass + """for sending post requests to YouTube Music""" def _send_get_request(self, url: str, params: Optional[Dict] = None) -> Response: - pass + """for sending get requests to YouTube Music""" @property def headers(self) -> Dict[str, str]: - pass + """property for getting request headers""" diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index e5c6d1ac..616add10 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -71,12 +71,12 @@ @overload def nav(root: Dict[str, Any], items: List[Any], none_if_absent: Literal[False] = False) -> Any: - ... + """overload for mypy only""" @overload def nav(root: Dict, items: List[Any], none_if_absent: Literal[True] = True) -> Optional[Any]: - ... + """overload for mypy only""" def nav(root: Dict, items: List[Any], none_if_absent: bool = False) -> Optional[Any]: From 9506bfd6d591d6e319e4ea89b820016942355979 Mon Sep 17 00:00:00 2001 From: sigma67 <16363825+sigma67@users.noreply.github.com> Date: Wed, 3 Jan 2024 21:15:00 +0100 Subject: [PATCH 236/238] fix #501 (#502) * fix #501 * enable tests on PR --- .github/workflows/coverage.yml | 6 +++--- .pre-commit-config.yaml | 1 - tests/auth/test_oauth.py | 21 +++++++++++++++------ ytmusicapi/auth/oauth/token.py | 3 ++- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index b11da693..6d9a19c2 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -4,9 +4,9 @@ on: push: branches: - master -# pull_request: -# branches: -# - master + pull_request: + branches: + - master jobs: build: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a9699913..597e0363 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,4 +11,3 @@ repos: rev: v1.8.0 hooks: - id: mypy - args: [--install-types, --non-interactive] diff --git a/tests/auth/test_oauth.py b/tests/auth/test_oauth.py index d2309283..ea12e546 100644 --- a/tests/auth/test_oauth.py +++ b/tests/auth/test_oauth.py @@ -1,4 +1,6 @@ import json +import os +import tempfile import time from pathlib import Path from typing import Any, Dict @@ -7,6 +9,7 @@ import pytest from requests import Response +from ytmusicapi.auth.oauth import OAuthToken from ytmusicapi.auth.types import AuthType from ytmusicapi.constants import OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET from ytmusicapi.setup import main @@ -37,21 +40,27 @@ def fixture_yt_alt_oauth(browser_filepath: str, alt_oauth_credentials: OAuthCred class TestOAuth: @mock.patch("requests.Response.json") @mock.patch("requests.Session.post") - def test_setup_oauth(self, session_mock, json_mock, oauth_filepath, blank_code, yt_oauth): + def test_setup_oauth(self, session_mock, json_mock, blank_code, config): session_mock.return_value = Response() - fresh_token = yt_oauth._token.as_dict() - json_mock.side_effect = [blank_code, fresh_token] + token_code = json.loads(config["auth"]["oauth_token"]) + json_mock.side_effect = [blank_code, token_code] + oauth_file = tempfile.NamedTemporaryFile(delete=False) + oauth_filepath = oauth_file.name with mock.patch("builtins.input", return_value="y"), mock.patch( "sys.argv", ["ytmusicapi", "oauth", "--file", oauth_filepath] - ): + ), mock.patch("webbrowser.open"): main() assert Path(oauth_filepath).exists() json_mock.side_effect = None with open(oauth_filepath, mode="r", encoding="utf8") as oauth_file: - string_oauth_token = oauth_file.read() + oauth_token = json.loads(oauth_file.read()) - YTMusic(string_oauth_token) + assert oauth_token["expires_at"] != 0 + assert OAuthToken.is_oauth(oauth_token) + + oauth_file.close() + os.unlink(oauth_filepath) def test_oauth_tokens(self, oauth_filepath: str, yt_oauth: YTMusic): # ensure instance initialized token diff --git a/ytmusicapi/auth/oauth/token.py b/ytmusicapi/auth/oauth/token.py index 0df8cb82..a0071f66 100644 --- a/ytmusicapi/auth/oauth/token.py +++ b/ytmusicapi/auth/oauth/token.py @@ -20,7 +20,7 @@ class Token: access_token: str refresh_token: str - expires_at: int + expires_at: int = 0 expires_in: int = 0 @staticmethod @@ -128,6 +128,7 @@ def prompt_for_token( input(f"Go to {url}, finish the login flow and press Enter when done, Ctrl-C to abort") raw_token = credentials.token_from_code(code["device_code"]) ref_token = cls(credentials=credentials, **raw_token) + ref_token.update(ref_token.as_dict()) if to_file: ref_token.local_cache = to_file return ref_token From a7f2827322400c19bb759dcbb8965167b7ecc901 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Wed, 3 Jan 2024 21:18:50 +0100 Subject: [PATCH 237/238] update LICENSE year to 2024 --- .flake8 | 11 ----------- LICENSE | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) delete mode 100644 .flake8 diff --git a/.flake8 b/.flake8 deleted file mode 100644 index bb60f516..00000000 --- a/.flake8 +++ /dev/null @@ -1,11 +0,0 @@ -[flake8] - -max-line-length = 99 -select = C,E,F,W,B,B950 -ignore = E203, E266, E731, E501, W503, F401, F403, F405 -builtins = _ -exclude = - .git - .github - __pycache__ - docs diff --git a/LICENSE b/LICENSE index eb25bca2..3498df2b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 sigma67 +Copyright (c) 2024 sigma67 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From 294fb81a1435cb7e01151b18ad8cea047c6af548 Mon Sep 17 00:00:00 2001 From: Marvin Schenkel Date: Sun, 21 Jan 2024 18:42:56 +0100 Subject: [PATCH 238/238] Fix OAUTH_CUSTOM_FULL mode (#527) * Fix OAUTH_CUSTOM_FULL mode * Fix OAUTH_CUSTOM_FULL mode --- ytmusicapi/ytmusic.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 53d3f0fe..97035ea7 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -211,7 +211,9 @@ def headers(self): if self.auth_type == AuthType.BROWSER: self._headers["authorization"] = get_authorization(self.sapisid + " " + self.origin) - elif self.auth_type in AuthType.oauth_types(): + # Do not set custom headers when using OAUTH_CUSTOM_FULL + # Full headers are provided by the downstream client in this scenario. + elif self.auth_type in [x for x in AuthType.oauth_types() if x != AuthType.OAUTH_CUSTOM_FULL]: self._headers["authorization"] = self._token.as_auth() self._headers["X-Goog-Request-Time"] = str(int(time.time()))