From 9cfc67c59f29ca8207ba153b4336b5c522755ab0 Mon Sep 17 00:00:00 2001 From: Hendrik Horstmann <65970327+heinrich26@users.noreply.github.com> Date: Thu, 10 Oct 2024 20:37:39 +0200 Subject: [PATCH 01/14] Added Lyrics w. Timestamps Added `get_lyrics_with_timestamps` to get lyrics with timestamps. The Method doesn't try to parse the response as normal lyrics, if no lyrics with timestamps are returned. (could be changed to do so tho, the format is the same) --- ytmusicapi/mixins/browsing.py | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index c3467b72..48eaa82f 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -865,6 +865,65 @@ def get_lyrics(self, browseId: str) -> dict: return lyrics + def get_lyrics_with_timestamps(self, browseId: str) -> dict: + """ + Returns lyrics of a song with timestamps, if available. + If no timestaps are given, this method won't replicate the behavior of `get_lyrics`! + + :param browseId: Lyrics browse id obtained from `get_watch_playlist` (startswith `MPLYt`) + :return: Dictionary with song lyrics. + + Example:: + + { + "lyrics": [ + { + "lyricLine": "I was a liar", + "cueRange": { + "startTimeMilliseconds": "9200", + "endTimeMilliseconds": "10630", + "metadata": { + "id": "1" + } + } + }, + { + "lyricLine": "I gave in to the fire", + "cueRange": { + "startTimeMilliseconds": "10680", + "endTimeMilliseconds": "12540", + "metadata": { + "id": "2" + } + } + }, + ], + "source": "Source: LyricFind" + } + + """ + lyrics = {} + if not browseId: + raise YTMusicUserError( + "Invalid browseId provided. This song might not have lyrics.") + + # change the Client to get lyrics with timestamps (mobile only) + copied_context_client = self.context["context"]["client"].copy() + self.context["context"]["client"].update({ + "clientName": "ANDROID_MUSIC", + "clientVersion": "7.21.50" + }) + response = self._send_request("browse", {"browseId": browseId}) + # restore the old context + self.context["context"]["client"] = copied_context_client + + base_path = ["contents", "elementRenderer", "newElement", "type", + "componentType", "model", "timedLyricsModel", "lyricsData"] + lyrics["lyrics"] = nav(response, [*base_path, "timedLyricsData"], True) + lyrics["source"] = nav(response, [*base_path, "sourceMessage"], True) + + return lyrics + def get_basejs_url(self): """ Extract the URL for the `base.js` script from YouTube Music. From a70de715ac554781ecdc99165d17a7e6b7582eff Mon Sep 17 00:00:00 2001 From: Hendrik Horstmann <65970327+heinrich26@users.noreply.github.com> Date: Fri, 11 Oct 2024 17:47:54 +0200 Subject: [PATCH 02/14] Update browsing.py --- 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 48eaa82f..0e7afc1a 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -865,7 +865,7 @@ def get_lyrics(self, browseId: str) -> dict: return lyrics - def get_lyrics_with_timestamps(self, browseId: str) -> dict: + def get_lyrics_with_timestamps(self, browseId: str) -> dict: """ Returns lyrics of a song with timestamps, if available. If no timestaps are given, this method won't replicate the behavior of `get_lyrics`! From c4d90de2f27d29089d0e3bea5d194e78c0dcd173 Mon Sep 17 00:00:00 2001 From: henrich26 Date: Sat, 12 Oct 2024 20:14:31 +0200 Subject: [PATCH 03/14] Combined both get_lyrics methods into one and fixed some typechecking errors --- tests/mixins/test_browsing.py | 21 ++++- ytmusicapi/__init__.py | 2 +- ytmusicapi/mixins/_protocol.py | 11 ++- ytmusicapi/mixins/_utils.py | 6 +- ytmusicapi/mixins/browsing.py | 157 ++++++++++++++++++++++++++++----- ytmusicapi/mixins/library.py | 12 +-- ytmusicapi/mixins/search.py | 8 +- ytmusicapi/mixins/uploads.py | 10 +-- ytmusicapi/navigation.py | 2 + ytmusicapi/ytmusic.py | 10 +-- 10 files changed, 192 insertions(+), 47 deletions(-) diff --git a/tests/mixins/test_browsing.py b/tests/mixins/test_browsing.py index 83c9909c..5eb0ec1d 100644 --- a/tests/mixins/test_browsing.py +++ b/tests/mixins/test_browsing.py @@ -6,6 +6,7 @@ import pytest from tests.test_helpers import is_ci +from ytmusicapi import LyricLine class TestBrowsing: @@ -173,9 +174,25 @@ def test_get_song_related_content(self, yt_oauth, sample_video): def test_get_lyrics(self, config, yt, sample_video): playlist = yt.get_watch_playlist(sample_video) + # test normal lyrics lyrics_song = yt.get_lyrics(playlist["lyrics"]) - assert lyrics_song["lyrics"] is not None - assert lyrics_song["source"] is not None + assert lyrics_song is not None + assert isinstance(lyrics_song["lyrics"], str) + assert lyrics_song["hasTimestamps"] is False + + # test lyrics with timestamps + lyrics_song = yt.get_lyrics(playlist["lyrics"], timestamps = True) + assert lyrics_song is not None + assert isinstance(lyrics_song["lyrics"], list) + assert lyrics_song["hasTimestamps"] is True + + # check the LyricLine object + song = lyrics_song["lyrics"][0] + assert isinstance(song, LyricLine) + assert isinstance(song.text, str) + assert isinstance(song.start_time, int) + assert isinstance(song.end_time, int) + assert isinstance(song.id, int) playlist = yt.get_watch_playlist(config["uploads"]["private_upload_id"]) assert playlist["lyrics"] is None diff --git a/ytmusicapi/__init__.py b/ytmusicapi/__init__.py index 4ed851d0..83bc676f 100644 --- a/ytmusicapi/__init__.py +++ b/ytmusicapi/__init__.py @@ -9,7 +9,7 @@ # package is not installed pass -__copyright__ = "Copyright 2023 sigma67" +__copyright__ = "Copyright 2024 sigma67" __license__ = "MIT" __title__ = "ytmusicapi" __all__ = ["YTMusic", "setup", "setup_oauth"] diff --git a/ytmusicapi/mixins/_protocol.py b/ytmusicapi/mixins/_protocol.py index 9b8aeb0f..7f58cbbf 100644 --- a/ytmusicapi/mixins/_protocol.py +++ b/ytmusicapi/mixins/_protocol.py @@ -1,8 +1,9 @@ """protocol that defines the functions available to mixins""" -from typing import Optional, Protocol +from typing import Mapping, Optional, Protocol from requests import Response +from requests.structures import CaseInsensitiveDict from ytmusicapi.auth.types import AuthType from ytmusicapi.parsers.i18n import Parser @@ -17,15 +18,21 @@ class MixinProtocol(Protocol): proxies: Optional[dict[str, str]] + context: dict + def _check_auth(self) -> None: """checks if self has authentication""" + ... def _send_request(self, endpoint: str, body: dict, additionalParams: str = "") -> dict: """for sending post requests to YouTube Music""" + ... def _send_get_request(self, url: str, params: Optional[dict] = None) -> Response: """for sending get requests to YouTube Music""" + ... @property - def headers(self) -> dict[str, str]: + def headers(self) -> CaseInsensitiveDict[str]: """property for getting request headers""" + ... diff --git a/ytmusicapi/mixins/_utils.py b/ytmusicapi/mixins/_utils.py index 530015fd..16aea2e8 100644 --- a/ytmusicapi/mixins/_utils.py +++ b/ytmusicapi/mixins/_utils.py @@ -1,9 +1,13 @@ import re from datetime import date +from typing import Literal from ytmusicapi.exceptions import YTMusicUserError +OrderType = Literal['a_to_z', 'z_to_a', 'recently_added'] + + def prepare_like_endpoint(rating): if rating == "LIKE": return "like/like" @@ -24,7 +28,7 @@ def validate_order_parameter(order): ) -def prepare_order_params(order): +def prepare_order_params(order: OrderType): 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 diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 0e7afc1a..fa4c0186 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -1,6 +1,7 @@ +from dataclasses import dataclass import re import warnings -from typing import Any, Optional +from typing import Any, Optional, TypedDict, cast from ytmusicapi.continuations import ( get_continuations, @@ -24,6 +25,49 @@ from ._utils import get_datestamp +@dataclass +class LyricLine: + """Represents a line of lyrics with timestamps (in milliseconds). + + Args: + text (str): The Songtext. + start_time (int): Begin of the lyric in milliseconds. + end_time (int): End of the lyric in milliseconds. + id (int): A Metadata-Id that probably uniquely identifies each lyric line. + """ + text: str + start_time: int + end_time: int + id: int + + @classmethod + def from_raw(cls, raw_lyric: dict): + """ + Converts lyrics in the format from the api to a more reasonable format + + :param raw_lyric: The raw lyric-data returned by the mobile api. + :return LyricLine: A `LyricLine` + """ + text = raw_lyric["lyricLine"] + cue_range = raw_lyric["cueRange"] + start_time = int(cue_range["startTimeMilliseconds"]) + end_time = int(cue_range["endTimeMilliseconds"]) + id = int(cue_range["metadata"]["id"]) + return cls(text, start_time, end_time, id) + + +class Lyrics(TypedDict): + lyrics: str + source: Optional[str] + hasTimestamps: Literal[False] + + +class TimedLyrics(TypedDict): + lyrics: list[LyricLine] + source: Optional[str] + hasTimestamps: Literal[True] + + class BrowsingMixin(MixinProtocol): def get_home(self, limit=3) -> list[dict]: """ @@ -271,13 +315,15 @@ def get_artist(self, channelId: str) -> dict: 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"]) + artist["songs"]["results"] = parse_playlist_items(musicShelf["contents"]) # type: ignore artist.update(self.parser.parse_channel_contents(results)) return artist + ArtistOrderType = Literal['Recency', 'Popularity', 'Alphabetical order'] + def get_artist_albums( - self, channelId: str, params: str, limit: Optional[int] = 100, order: Optional[str] = None + self, channelId: str, params: str, limit: Optional[int] = 100, order: Optional[ArtistOrderType] = None ) -> list[dict]: """ Get the full list of an artist's albums, singles or shows @@ -836,34 +882,101 @@ def get_song_related(self, browseId: str): sections = nav(response, ["contents", *SECTION_LIST]) return parse_mixed_content(sections) - def get_lyrics(self, browseId: str) -> dict: + + @overload + def get_lyrics(self, browseId: str, timestamps: Literal[False] = False) -> Optional[Lyrics]: + """overload for mypy only""" + + @overload + def get_lyrics(self, browseId: str, timestamps: Literal[True] = True) -> Optional[Lyrics |TimedLyrics]: + """overload for mypy only""" + + def get_lyrics(self, browseId: str, timestamps: bool = False) -> Optional[Lyrics |TimedLyrics]: """ - Returns lyrics of a song or video. + Returns lyrics of a song or video. When `timestamps` is set, lyrics are returned with + timestamps, if available. - :param browseId: Lyrics browse id obtained from `get_watch_playlist` - :return: Dictionary with song lyrics. + :param browseId: Lyrics browse-id obtained from :py:func:`get_watch_playlist` (startswith `MPLYt`). + :param timestamps: Whether to return bare lyrics or lyrics with timestamps, if available. + :return: Dictionary with song lyrics or `None`, if no lyrics are found. + The `hasTimestamps`-key determines the format of the data. - Example:: + + Example when `timestamps` is set to `False`, or not timestamps are available:: - { - "lyrics": "Today is gonna be the day\\nThat they're gonna throw it back to you\\n", - "source": "Source: LyricFind" - } + { + "lyrics": "Today is gonna be the day\\nThat they're gonna throw it back to you\\n", + "source": "Source: LyricFind", + "hasTimestamps": False + } + + Example when `timestamps` is set to `True` and timestamps are available:: + + { + "lyrics": [ + LyricLine( + text="I was a liar", + start_time=9200, + end_time=10630, + id=1 + ), + LyricLine( + text="I gave in to the fire", + start_time=10680, + end_time=12540, + id=2 + ), + ], + "source": "Source: LyricFind", + "hasTimestamps": True + } """ - lyrics = {} + + lyrics: dict = {} if not browseId: - raise YTMusicUserError("Invalid browseId provided. This song might not have lyrics.") + raise YTMusicUserError( + "Invalid browseId provided. This song might not have lyrics.") + + if timestamps: + # change the client to get lyrics with timestamps (mobile only) + copied_context_client = self.context["context"]["client"].copy() + self.context["context"]["client"].update({ + "clientName": "ANDROID_MUSIC", + "clientVersion": "7.21.50" + }) 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 + if timestamps: + # restore the old context + self.context["context"]["client"] = copied_context_client # type: ignore + + # unpack the response + + # we got lyrics with timestamps + if timestamps and (data := nav(response, TIMESTAMPED_LYRICS, True)) is not None: + assert isinstance(data, dict) + + if not "timedLyricsData" in data: + return None + + lyrics["lyrics"] = list(map(LyricLine.from_raw, data["timedLyricsData"])) + lyrics["source"] = data.get("sourceMessage") + lyrics["hasTimestamps"] = True + else: + lyrics["lyrics"] = nav( + response, ["contents", *SECTION_LIST_ITEM, *DESCRIPTION_SHELF, *DESCRIPTION], True + ) + + if lyrics["lyrics"] is None: + return None + + lyrics["source"] = nav( + response, ["contents", *SECTION_LIST_ITEM, *DESCRIPTION_SHELF, "footer", *RUN_TEXT], True + ) + + return cast(Lyrics | TimedLyrics, lyrics) def get_lyrics_with_timestamps(self, browseId: str) -> dict: """ @@ -935,7 +1048,7 @@ def get_basejs_url(self): if match is None: raise YTMusicError("Could not identify the URL for base.js player.") - return YTM_DOMAIN + match.group(1) + return cast(str, YTM_DOMAIN + match.group(1)) def get_signatureTimestamp(self, url: Optional[str] = None) -> int: """ diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index 99443326..4d35d2c6 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -46,7 +46,7 @@ def get_library_playlists(self, limit: Optional[int] = 25) -> list[dict]: return playlists def get_library_songs( - self, limit: int = 25, validate_responses: bool = False, order: Optional[str] = None + self, limit: int = 25, validate_responses: bool = False, order: Optional[OrderType] = None ) -> list[dict]: """ Gets the songs in the user's library (liked videos are not included). @@ -116,7 +116,7 @@ def get_library_songs( return songs - def get_library_albums(self, limit: int = 25, order: Optional[str] = None) -> list[dict]: + def get_library_albums(self, limit: int = 25, order: Optional[OrderType] = None) -> list[dict]: """ Gets the albums in the user's library. @@ -151,7 +151,7 @@ def get_library_albums(self, limit: int = 25, order: Optional[str] = None) -> li response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_library_artists(self, limit: int = 25, order: Optional[str] = None) -> list[dict]: + def get_library_artists(self, limit: int = 25, order: Optional[OrderType] = None) -> list[dict]: """ Gets the artists of the songs in the user's library. @@ -179,7 +179,7 @@ def get_library_artists(self, limit: int = 25, order: Optional[str] = None) -> l response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_library_subscriptions(self, limit: int = 25, order: Optional[str] = None) -> list[dict]: + def get_library_subscriptions(self, limit: int = 25, order: Optional[OrderType] = None) -> list[dict]: """ Gets the artists the user has subscribed to. @@ -198,7 +198,7 @@ def get_library_subscriptions(self, limit: int = 25, order: Optional[str] = None response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_library_podcasts(self, limit: int = 25, order: Optional[str] = None) -> list[dict]: + def get_library_podcasts(self, limit: int = 25, order: Optional[OrderType] = None) -> list[dict]: """ Get podcasts the user has added to the library @@ -244,7 +244,7 @@ def get_library_podcasts(self, limit: int = 25, order: Optional[str] = None) -> response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_library_channels(self, limit: int = 25, order: Optional[str] = None) -> list[dict]: + def get_library_channels(self, limit: int = 25, order: Optional[OrderType] = None) -> list[dict]: """ Get channels the user has added to the library diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index 96074616..e4d21b91 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -6,12 +6,14 @@ from ytmusicapi.parsers.search import * +FilterType = Literal['songs', 'videos', 'albums', 'artists', 'playlists', 'community_playlists', 'featured_playlists', 'uploads'] + class SearchMixin(MixinProtocol): def search( self, query: str, - filter: Optional[str] = None, - scope: Optional[str] = None, + filter: Optional[FilterType] = None, + scope: Optional[Literal["library", "uploads"]] = None, limit: int = 20, ignore_spelling: bool = False, ) -> list[dict]: @@ -204,7 +206,7 @@ def search( if filter and "playlists" in filter: filter = "playlists" elif scope == scopes[1]: - filter = scopes[1] + filter = scopes[1] # type:ignore for res in section_list: result_type = category = None diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index a0c6ecb6..99caf450 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -19,16 +19,16 @@ from ..enums import ResponseStatus from ..exceptions import YTMusicUserError from ._protocol import MixinProtocol -from ._utils import prepare_order_params, validate_order_parameter +from ._utils import OrderType, prepare_order_params, validate_order_parameter class UploadsMixin(MixinProtocol): - def get_library_upload_songs(self, limit: Optional[int] = 25, order: Optional[str] = None) -> list[dict]: + def get_library_upload_songs(self, limit: Optional[int] = 25, order: Optional[OrderType] = None) -> list[dict]: """ Returns a list of uploaded songs :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. + :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. Each item is in the following format:: @@ -70,7 +70,7 @@ def get_library_upload_songs(self, limit: Optional[int] = 25, order: Optional[st return songs - def get_library_upload_albums(self, limit: Optional[int] = 25, order: Optional[str] = None) -> list[dict]: + def get_library_upload_albums(self, limit: Optional[int] = 25, order: Optional[OrderType] = None) -> list[dict]: """ Gets the albums of uploaded songs in the user's library. @@ -90,7 +90,7 @@ def get_library_upload_albums(self, limit: Optional[int] = 25, order: Optional[s ) def get_library_upload_artists( - self, limit: Optional[int] = 25, order: Optional[str] = None + self, limit: Optional[int] = 25, order: Optional[OrderType] = None ) -> list[dict]: """ Gets the artists of uploaded songs in the user's library. diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index 7bf89fa3..d968b6ef 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -88,6 +88,8 @@ CAROUSEL_TITLE = [*HEADER, "musicCarouselShelfBasicHeaderRenderer", *TITLE] CARD_SHELF_TITLE = [*HEADER, "musicCardShelfHeaderBasicRenderer", *TITLE_TEXT] FRAMEWORK_MUTATIONS = ["frameworkUpdates", "entityBatchUpdate", "mutations"] +TIMESTAMPED_LYRICS = ["contents", "elementRenderer", "newElement", "type", + "componentType", "model", "timedLyricsModel", "lyricsData"] @overload diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 9847da68..9666db1e 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -5,7 +5,7 @@ from contextlib import suppress from functools import partial from pathlib import Path -from typing import Optional, Union +from typing import Optional, Union, cast import requests from requests import Response @@ -179,7 +179,7 @@ def __init__( try: 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 = cast(str, self.base_headers.get("origin", self.base_headers.get("x-origin"))) except KeyError: raise YTMusicUserError("Your cookie is missing the required value __Secure-3PAPISID") @@ -189,16 +189,16 @@ def base_headers(self): if self.auth_type == AuthType.BROWSER or self.auth_type == AuthType.OAUTH_CUSTOM_FULL: self._base_headers = self._input_dict else: - self._base_headers = { + self._base_headers = CaseInsensitiveDict({ "user-agent": USER_AGENT, "accept": "*/*", "accept-encoding": "gzip, deflate", "content-type": "application/json", "content-encoding": "gzip", "origin": YTM_DOMAIN, - } + }) - return self._base_headers + return cast(CaseInsensitiveDict[str], self._base_headers) @property def headers(self): From c85369fa178e094ef91197bdb027c8b70419e6c2 Mon Sep 17 00:00:00 2001 From: henrich26 Date: Sat, 12 Oct 2024 20:20:55 +0200 Subject: [PATCH 04/14] Fixed a missing hasTimestamps and added the doccomments to the overloads, because vscode didn't show them otherwise --- ytmusicapi/mixins/browsing.py | 87 +++++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index fa4c0186..3bf71139 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -885,13 +885,91 @@ def get_song_related(self, browseId: str): @overload def get_lyrics(self, browseId: str, timestamps: Literal[False] = False) -> Optional[Lyrics]: - """overload for mypy only""" + """ + Returns lyrics of a song or video. When `timestamps` is set, lyrics are returned with + timestamps, if available. + + :param browseId: Lyrics browse-id obtained from :py:func:`get_watch_playlist` (startswith `MPLYt`). + :param timestamps: Whether to return bare lyrics or lyrics with timestamps, if available. + :return: Dictionary with song lyrics or `None`, if no lyrics are found. + The `hasTimestamps`-key determines the format of the data. + + + Example when `timestamps` is set to `False`, or not timestamps are available:: + + { + "lyrics": "Today is gonna be the day\\nThat they're gonna throw it back to you\\n", + "source": "Source: LyricFind", + "hasTimestamps": False + } + + Example when `timestamps` is set to `True` and timestamps are available:: + + { + "lyrics": [ + LyricLine( + text="I was a liar", + start_time=9200, + end_time=10630, + id=1 + ), + LyricLine( + text="I gave in to the fire", + start_time=10680, + end_time=12540, + id=2 + ), + ], + "source": "Source: LyricFind", + "hasTimestamps": True + } + + """ @overload - def get_lyrics(self, browseId: str, timestamps: Literal[True] = True) -> Optional[Lyrics |TimedLyrics]: - """overload for mypy only""" + def get_lyrics(self, browseId: str, timestamps: Literal[True] = True) -> Optional[Lyrics|TimedLyrics]: + """ + Returns lyrics of a song or video. When `timestamps` is set, lyrics are returned with + timestamps, if available. + + :param browseId: Lyrics browse-id obtained from :py:func:`get_watch_playlist` (startswith `MPLYt`). + :param timestamps: Whether to return bare lyrics or lyrics with timestamps, if available. + :return: Dictionary with song lyrics or `None`, if no lyrics are found. + The `hasTimestamps`-key determines the format of the data. + + + Example when `timestamps` is set to `False`, or not timestamps are available:: + + { + "lyrics": "Today is gonna be the day\\nThat they're gonna throw it back to you\\n", + "source": "Source: LyricFind", + "hasTimestamps": False + } + + Example when `timestamps` is set to `True` and timestamps are available:: + + { + "lyrics": [ + LyricLine( + text="I was a liar", + start_time=9200, + end_time=10630, + id=1 + ), + LyricLine( + text="I gave in to the fire", + start_time=10680, + end_time=12540, + id=2 + ), + ], + "source": "Source: LyricFind", + "hasTimestamps": True + } + + """ - def get_lyrics(self, browseId: str, timestamps: bool = False) -> Optional[Lyrics |TimedLyrics]: + def get_lyrics(self, browseId: str, timestamps: bool = False) -> Optional[Lyrics|TimedLyrics]: """ Returns lyrics of a song or video. When `timestamps` is set, lyrics are returned with timestamps, if available. @@ -975,6 +1053,7 @@ def get_lyrics(self, browseId: str, timestamps: bool = False) -> Optional[Lyrics lyrics["source"] = nav( response, ["contents", *SECTION_LIST_ITEM, *DESCRIPTION_SHELF, "footer", *RUN_TEXT], True ) + lyrics["hasTimestamps"] = False return cast(Lyrics | TimedLyrics, lyrics) From a657a370c6b5b047dd63d105881a0cacc8303b53 Mon Sep 17 00:00:00 2001 From: Hendrik Horstmann <65970327+heinrich26@users.noreply.github.com> Date: Sat, 12 Oct 2024 20:34:40 +0200 Subject: [PATCH 05/14] Removed the old get_lyrics_with_timestamps method because idk where it came from... --- ytmusicapi/mixins/browsing.py | 59 ----------------------------------- 1 file changed, 59 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 3bf71139..029e0573 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -1057,65 +1057,6 @@ def get_lyrics(self, browseId: str, timestamps: bool = False) -> Optional[Lyrics return cast(Lyrics | TimedLyrics, lyrics) - def get_lyrics_with_timestamps(self, browseId: str) -> dict: - """ - Returns lyrics of a song with timestamps, if available. - If no timestaps are given, this method won't replicate the behavior of `get_lyrics`! - - :param browseId: Lyrics browse id obtained from `get_watch_playlist` (startswith `MPLYt`) - :return: Dictionary with song lyrics. - - Example:: - - { - "lyrics": [ - { - "lyricLine": "I was a liar", - "cueRange": { - "startTimeMilliseconds": "9200", - "endTimeMilliseconds": "10630", - "metadata": { - "id": "1" - } - } - }, - { - "lyricLine": "I gave in to the fire", - "cueRange": { - "startTimeMilliseconds": "10680", - "endTimeMilliseconds": "12540", - "metadata": { - "id": "2" - } - } - }, - ], - "source": "Source: LyricFind" - } - - """ - lyrics = {} - if not browseId: - raise YTMusicUserError( - "Invalid browseId provided. This song might not have lyrics.") - - # change the Client to get lyrics with timestamps (mobile only) - copied_context_client = self.context["context"]["client"].copy() - self.context["context"]["client"].update({ - "clientName": "ANDROID_MUSIC", - "clientVersion": "7.21.50" - }) - response = self._send_request("browse", {"browseId": browseId}) - # restore the old context - self.context["context"]["client"] = copied_context_client - - base_path = ["contents", "elementRenderer", "newElement", "type", - "componentType", "model", "timedLyricsModel", "lyricsData"] - lyrics["lyrics"] = nav(response, [*base_path, "timedLyricsData"], True) - lyrics["source"] = nav(response, [*base_path, "sourceMessage"], True) - - return lyrics - def get_basejs_url(self): """ Extract the URL for the `base.js` script from YouTube Music. From 2ffc1369229704a9a41fa4619d6394e38601a04e Mon Sep 17 00:00:00 2001 From: henrich26 Date: Wed, 16 Oct 2024 23:33:31 +0200 Subject: [PATCH 06/14] fixed remaining issues --- tests/mixins/test_browsing.py | 7 +- ytmusicapi/mixins/_protocol.py | 11 +-- ytmusicapi/mixins/_utils.py | 4 +- ytmusicapi/mixins/browsing.py | 160 ++++----------------------------- ytmusicapi/mixins/library.py | 12 +-- ytmusicapi/mixins/search.py | 8 +- ytmusicapi/mixins/uploads.py | 8 +- ytmusicapi/models/__init__.py | 3 + ytmusicapi/models/lyrics.py | 45 ++++++++++ ytmusicapi/ytmusic.py | 38 ++++++++ 10 files changed, 126 insertions(+), 170 deletions(-) create mode 100644 ytmusicapi/models/__init__.py create mode 100644 ytmusicapi/models/lyrics.py diff --git a/tests/mixins/test_browsing.py b/tests/mixins/test_browsing.py index 5eb0ec1d..29945c57 100644 --- a/tests/mixins/test_browsing.py +++ b/tests/mixins/test_browsing.py @@ -6,7 +6,7 @@ import pytest from tests.test_helpers import is_ci -from ytmusicapi import LyricLine +from ytmusicapi.models.lyrics import LyricLine class TestBrowsing: @@ -183,15 +183,14 @@ def test_get_lyrics(self, config, yt, sample_video): # test lyrics with timestamps lyrics_song = yt.get_lyrics(playlist["lyrics"], timestamps = True) assert lyrics_song is not None - assert isinstance(lyrics_song["lyrics"], list) + assert len(lyrics_song["lyrics"]) >= 1 assert lyrics_song["hasTimestamps"] is True # check the LyricLine object song = lyrics_song["lyrics"][0] assert isinstance(song, LyricLine) assert isinstance(song.text, str) - assert isinstance(song.start_time, int) - assert isinstance(song.end_time, int) + assert song.start_time <= song.end_time assert isinstance(song.id, int) playlist = yt.get_watch_playlist(config["uploads"]["private_upload_id"]) diff --git a/ytmusicapi/mixins/_protocol.py b/ytmusicapi/mixins/_protocol.py index 7f58cbbf..5620a761 100644 --- a/ytmusicapi/mixins/_protocol.py +++ b/ytmusicapi/mixins/_protocol.py @@ -1,6 +1,7 @@ """protocol that defines the functions available to mixins""" -from typing import Mapping, Optional, Protocol +from typing import Optional, Protocol +from contextlib import contextmanager from requests import Response from requests.structures import CaseInsensitiveDict @@ -22,17 +23,17 @@ class MixinProtocol(Protocol): def _check_auth(self) -> None: """checks if self has authentication""" - ... def _send_request(self, endpoint: str, body: dict, additionalParams: str = "") -> dict: """for sending post requests to YouTube Music""" - ... def _send_get_request(self, url: str, params: Optional[dict] = None) -> Response: """for sending get requests to YouTube Music""" - ... + + @contextmanager + def as_mobile(self): + """context-manager, that allows requests as the YouTube Music Mobile-App""" @property def headers(self) -> CaseInsensitiveDict[str]: """property for getting request headers""" - ... diff --git a/ytmusicapi/mixins/_utils.py b/ytmusicapi/mixins/_utils.py index 16aea2e8..cc8a5491 100644 --- a/ytmusicapi/mixins/_utils.py +++ b/ytmusicapi/mixins/_utils.py @@ -5,7 +5,7 @@ from ytmusicapi.exceptions import YTMusicUserError -OrderType = Literal['a_to_z', 'z_to_a', 'recently_added'] +LibraryOrderType = Literal['a_to_z', 'z_to_a', 'recently_added'] def prepare_like_endpoint(rating): @@ -28,7 +28,7 @@ def validate_order_parameter(order): ) -def prepare_order_params(order: OrderType): +def prepare_order_params(order: LibraryOrderType): 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 diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 029e0573..fdf94c57 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -1,7 +1,6 @@ -from dataclasses import dataclass import re import warnings -from typing import Any, Optional, TypedDict, cast +from typing import Any, Optional, cast from ytmusicapi.continuations import ( get_continuations, @@ -18,6 +17,7 @@ ) from ytmusicapi.parsers.library import parse_albums from ytmusicapi.parsers.playlists import parse_playlist_items +from ytmusicapi.models.lyrics import Lyrics, TimedLyrics, LyricLine from ..exceptions import YTMusicError, YTMusicUserError from ..navigation import * @@ -25,49 +25,6 @@ from ._utils import get_datestamp -@dataclass -class LyricLine: - """Represents a line of lyrics with timestamps (in milliseconds). - - Args: - text (str): The Songtext. - start_time (int): Begin of the lyric in milliseconds. - end_time (int): End of the lyric in milliseconds. - id (int): A Metadata-Id that probably uniquely identifies each lyric line. - """ - text: str - start_time: int - end_time: int - id: int - - @classmethod - def from_raw(cls, raw_lyric: dict): - """ - Converts lyrics in the format from the api to a more reasonable format - - :param raw_lyric: The raw lyric-data returned by the mobile api. - :return LyricLine: A `LyricLine` - """ - text = raw_lyric["lyricLine"] - cue_range = raw_lyric["cueRange"] - start_time = int(cue_range["startTimeMilliseconds"]) - end_time = int(cue_range["endTimeMilliseconds"]) - id = int(cue_range["metadata"]["id"]) - return cls(text, start_time, end_time, id) - - -class Lyrics(TypedDict): - lyrics: str - source: Optional[str] - hasTimestamps: Literal[False] - - -class TimedLyrics(TypedDict): - lyrics: list[LyricLine] - source: Optional[str] - hasTimestamps: Literal[True] - - class BrowsingMixin(MixinProtocol): def get_home(self, limit=3) -> list[dict]: """ @@ -315,7 +272,7 @@ def get_artist(self, channelId: str) -> dict: 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"]) # type: ignore + artist["songs"]["results"] = parse_playlist_items(musicShelf["contents"]) artist.update(self.parser.parse_channel_contents(results)) return artist @@ -885,102 +842,24 @@ def get_song_related(self, browseId: str): @overload def get_lyrics(self, browseId: str, timestamps: Literal[False] = False) -> Optional[Lyrics]: - """ - Returns lyrics of a song or video. When `timestamps` is set, lyrics are returned with - timestamps, if available. - - :param browseId: Lyrics browse-id obtained from :py:func:`get_watch_playlist` (startswith `MPLYt`). - :param timestamps: Whether to return bare lyrics or lyrics with timestamps, if available. - :return: Dictionary with song lyrics or `None`, if no lyrics are found. - The `hasTimestamps`-key determines the format of the data. - - - Example when `timestamps` is set to `False`, or not timestamps are available:: - - { - "lyrics": "Today is gonna be the day\\nThat they're gonna throw it back to you\\n", - "source": "Source: LyricFind", - "hasTimestamps": False - } - - Example when `timestamps` is set to `True` and timestamps are available:: - - { - "lyrics": [ - LyricLine( - text="I was a liar", - start_time=9200, - end_time=10630, - id=1 - ), - LyricLine( - text="I gave in to the fire", - start_time=10680, - end_time=12540, - id=2 - ), - ], - "source": "Source: LyricFind", - "hasTimestamps": True - } - - """ + """overload for mypy only""" @overload def get_lyrics(self, browseId: str, timestamps: Literal[True] = True) -> Optional[Lyrics|TimedLyrics]: - """ - Returns lyrics of a song or video. When `timestamps` is set, lyrics are returned with - timestamps, if available. - - :param browseId: Lyrics browse-id obtained from :py:func:`get_watch_playlist` (startswith `MPLYt`). - :param timestamps: Whether to return bare lyrics or lyrics with timestamps, if available. - :return: Dictionary with song lyrics or `None`, if no lyrics are found. - The `hasTimestamps`-key determines the format of the data. - - - Example when `timestamps` is set to `False`, or not timestamps are available:: - - { - "lyrics": "Today is gonna be the day\\nThat they're gonna throw it back to you\\n", - "source": "Source: LyricFind", - "hasTimestamps": False - } - - Example when `timestamps` is set to `True` and timestamps are available:: + """overload for mypy only""" - { - "lyrics": [ - LyricLine( - text="I was a liar", - start_time=9200, - end_time=10630, - id=1 - ), - LyricLine( - text="I gave in to the fire", - start_time=10680, - end_time=12540, - id=2 - ), - ], - "source": "Source: LyricFind", - "hasTimestamps": True - } - - """ - - def get_lyrics(self, browseId: str, timestamps: bool = False) -> Optional[Lyrics|TimedLyrics]: + def get_lyrics(self, browseId: str, timestamps: Optional[bool] = False) -> Optional[Lyrics|TimedLyrics]: """ Returns lyrics of a song or video. When `timestamps` is set, lyrics are returned with timestamps, if available. - :param browseId: Lyrics browse-id obtained from :py:func:`get_watch_playlist` (startswith `MPLYt`). - :param timestamps: Whether to return bare lyrics or lyrics with timestamps, if available. + :param browseId: Lyrics browse-id obtained from :py:func:`get_watch_playlist` (startswith `MPLYt...`). + :param timestamps: Optional. Whether to return bare lyrics or lyrics with timestamps, if available. (Default: `False`) :return: Dictionary with song lyrics or `None`, if no lyrics are found. The `hasTimestamps`-key determines the format of the data. - Example when `timestamps` is set to `False`, or not timestamps are available:: + Example when `timestamps=False`, or no timestamps are available:: { "lyrics": "Today is gonna be the day\\nThat they're gonna throw it back to you\\n", @@ -1017,18 +896,11 @@ def get_lyrics(self, browseId: str, timestamps: bool = False) -> Optional[Lyrics "Invalid browseId provided. This song might not have lyrics.") if timestamps: - # change the client to get lyrics with timestamps (mobile only) - copied_context_client = self.context["context"]["client"].copy() - self.context["context"]["client"].update({ - "clientName": "ANDROID_MUSIC", - "clientVersion": "7.21.50" - }) - - response = self._send_request("browse", {"browseId": browseId}) - - if timestamps: - # restore the old context - self.context["context"]["client"] = copied_context_client # type: ignore + # changes and restores the client to get lyrics with timestamps (mobile only) + with self.as_mobile(): + response = self._send_request("browse", {"browseId": browseId}) + else: + response = self._send_request("browse", {"browseId": browseId}) # unpack the response @@ -1057,7 +929,7 @@ def get_lyrics(self, browseId: str, timestamps: bool = False) -> Optional[Lyrics return cast(Lyrics | TimedLyrics, lyrics) - def get_basejs_url(self): + def get_basejs_url(self) -> str: """ Extract the URL for the `base.js` script from YouTube Music. @@ -1068,7 +940,7 @@ def get_basejs_url(self): if match is None: raise YTMusicError("Could not identify the URL for base.js player.") - return cast(str, YTM_DOMAIN + match.group(1)) + return YTM_DOMAIN + match.group(1) def get_signatureTimestamp(self, url: Optional[str] = None) -> int: """ diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index 4d35d2c6..22edd246 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -46,7 +46,7 @@ def get_library_playlists(self, limit: Optional[int] = 25) -> list[dict]: return playlists def get_library_songs( - self, limit: int = 25, validate_responses: bool = False, order: Optional[OrderType] = None + self, limit: int = 25, validate_responses: bool = False, order: Optional[LibraryOrderType] = None ) -> list[dict]: """ Gets the songs in the user's library (liked videos are not included). @@ -116,7 +116,7 @@ def get_library_songs( return songs - def get_library_albums(self, limit: int = 25, order: Optional[OrderType] = None) -> list[dict]: + def get_library_albums(self, limit: int = 25, order: Optional[LibraryOrderType] = None) -> list[dict]: """ Gets the albums in the user's library. @@ -151,7 +151,7 @@ def get_library_albums(self, limit: int = 25, order: Optional[OrderType] = None) response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_library_artists(self, limit: int = 25, order: Optional[OrderType] = None) -> list[dict]: + def get_library_artists(self, limit: int = 25, order: Optional[LibraryOrderType] = None) -> list[dict]: """ Gets the artists of the songs in the user's library. @@ -179,7 +179,7 @@ def get_library_artists(self, limit: int = 25, order: Optional[OrderType] = None response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_library_subscriptions(self, limit: int = 25, order: Optional[OrderType] = None) -> list[dict]: + def get_library_subscriptions(self, limit: int = 25, order: Optional[LibraryOrderType] = None) -> list[dict]: """ Gets the artists the user has subscribed to. @@ -198,7 +198,7 @@ def get_library_subscriptions(self, limit: int = 25, order: Optional[OrderType] response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_library_podcasts(self, limit: int = 25, order: Optional[OrderType] = None) -> list[dict]: + def get_library_podcasts(self, limit: int = 25, order: Optional[LibraryOrderType] = None) -> list[dict]: """ Get podcasts the user has added to the library @@ -244,7 +244,7 @@ def get_library_podcasts(self, limit: int = 25, order: Optional[OrderType] = Non response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_library_channels(self, limit: int = 25, order: Optional[OrderType] = None) -> list[dict]: + def get_library_channels(self, limit: int = 25, order: Optional[LibraryOrderType] = None) -> list[dict]: """ Get channels the user has added to the library diff --git a/ytmusicapi/mixins/search.py b/ytmusicapi/mixins/search.py index e4d21b91..96074616 100644 --- a/ytmusicapi/mixins/search.py +++ b/ytmusicapi/mixins/search.py @@ -6,14 +6,12 @@ from ytmusicapi.parsers.search import * -FilterType = Literal['songs', 'videos', 'albums', 'artists', 'playlists', 'community_playlists', 'featured_playlists', 'uploads'] - class SearchMixin(MixinProtocol): def search( self, query: str, - filter: Optional[FilterType] = None, - scope: Optional[Literal["library", "uploads"]] = None, + filter: Optional[str] = None, + scope: Optional[str] = None, limit: int = 20, ignore_spelling: bool = False, ) -> list[dict]: @@ -206,7 +204,7 @@ def search( if filter and "playlists" in filter: filter = "playlists" elif scope == scopes[1]: - filter = scopes[1] # type:ignore + filter = scopes[1] for res in section_list: result_type = category = None diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index 99caf450..07dc6e58 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -19,11 +19,11 @@ from ..enums import ResponseStatus from ..exceptions import YTMusicUserError from ._protocol import MixinProtocol -from ._utils import OrderType, prepare_order_params, validate_order_parameter +from ._utils import LibraryOrderType, prepare_order_params, validate_order_parameter class UploadsMixin(MixinProtocol): - def get_library_upload_songs(self, limit: Optional[int] = 25, order: Optional[OrderType] = None) -> list[dict]: + def get_library_upload_songs(self, limit: Optional[int] = 25, order: Optional[LibraryOrderType] = None) -> list[dict]: """ Returns a list of uploaded songs @@ -70,7 +70,7 @@ def get_library_upload_songs(self, limit: Optional[int] = 25, order: Optional[Or return songs - def get_library_upload_albums(self, limit: Optional[int] = 25, order: Optional[OrderType] = None) -> list[dict]: + def get_library_upload_albums(self, limit: Optional[int] = 25, order: Optional[LibraryOrderType] = None) -> list[dict]: """ Gets the albums of uploaded songs in the user's library. @@ -90,7 +90,7 @@ def get_library_upload_albums(self, limit: Optional[int] = 25, order: Optional[O ) def get_library_upload_artists( - self, limit: Optional[int] = 25, order: Optional[OrderType] = None + self, limit: Optional[int] = 25, order: Optional[LibraryOrderType] = None ) -> list[dict]: """ Gets the artists of uploaded songs in the user's library. diff --git a/ytmusicapi/models/__init__.py b/ytmusicapi/models/__init__.py new file mode 100644 index 00000000..3cc09e58 --- /dev/null +++ b/ytmusicapi/models/__init__.py @@ -0,0 +1,3 @@ +from .lyrics import LyricLine, Lyrics, TimedLyrics + +__all__ = ["LyricLine", "Lyrics", "TimedLyrics"] diff --git a/ytmusicapi/models/lyrics.py b/ytmusicapi/models/lyrics.py new file mode 100644 index 00000000..e97198dd --- /dev/null +++ b/ytmusicapi/models/lyrics.py @@ -0,0 +1,45 @@ +from dataclasses import dataclass +from typing import TypedDict, Optional, Literal + + +@dataclass +class LyricLine: + """Represents a line of lyrics with timestamps (in milliseconds). + + Args: + text (str): The Songtext. + start_time (int): Begin of the lyric in milliseconds. + end_time (int): End of the lyric in milliseconds. + id (int): A Metadata-Id that probably uniquely identifies each lyric line. + """ + text: str + start_time: int + end_time: int + id: int + + @classmethod + def from_raw(cls, raw_lyric: dict): + """ + Converts lyrics in the format from the api to a more reasonable format + + :param raw_lyric: The raw lyric-data returned by the mobile api. + :return LyricLine: A `LyricLine` + """ + text = raw_lyric["lyricLine"] + cue_range = raw_lyric["cueRange"] + start_time = int(cue_range["startTimeMilliseconds"]) + end_time = int(cue_range["endTimeMilliseconds"]) + id = int(cue_range["metadata"]["id"]) + return cls(text, start_time, end_time, id) + + +class Lyrics(TypedDict): + lyrics: str + source: Optional[str] + hasTimestamps: Literal[False] + + +class TimedLyrics(TypedDict): + lyrics: list[LyricLine] + source: Optional[str] + hasTimestamps: Literal[True] diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 9666db1e..4d836617 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -6,6 +6,7 @@ from functools import partial from pathlib import Path from typing import Optional, Union, cast +from contextlib import contextmanager import requests from requests import Response @@ -218,6 +219,43 @@ def headers(self): return self._headers + @contextmanager + def as_mobile(self): + """ + Not thread-safe! + ---------------- + + Temporarily changes the `context` to enable different results + from the API, meant for the Android mobile-app. + All calls inside the `with`-statement with emulate mobile behavior. + + This context-manager has no `enter_result`, as it operates in-place + and only temporarily alters the underlying `YTMusic`-object. + + + Example:: + + with yt.as_mobile(): + yt._send_request(...) # results as mobile-app + + yt._send_request(...) # back to normal, like web-app + + """ + + # change the context to emulate a mobile-app (Android) + copied_context_client = self.context["context"]["client"].copy() + self.context["context"]["client"].update({ + "clientName": "ANDROID_MUSIC", + "clientVersion": "7.21.50" + }) + + # this will not catch errors + try: + yield None + finally: + # safely restore the old context + self.context["context"]["client"] = copied_context_client + def _send_request(self, endpoint: str, body: dict, additionalParams: str = "") -> dict: body.update(self.context) From 83b6e92edb978a62ead6171e4539ffcf60d78017 Mon Sep 17 00:00:00 2001 From: Hendrik Horstmann <65970327+heinrich26@users.noreply.github.com> Date: Wed, 16 Oct 2024 23:51:17 +0200 Subject: [PATCH 07/14] Update uploads.py reverted some changes that I moved to an extra PR --- 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 07dc6e58..026d8fa7 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -28,7 +28,7 @@ def get_library_upload_songs(self, limit: Optional[int] = 25, order: Optional[Li Returns a list of uploaded songs :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. + :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. Each item is in the following format:: From 47a85049daa80da185d1568b2db25a58b1c16e98 Mon Sep 17 00:00:00 2001 From: henrich26 Date: Wed, 16 Oct 2024 23:58:15 +0200 Subject: [PATCH 08/14] removed variable `context` from the Mixin, as it's use was replaced by `yt.as_mobile()` --- ytmusicapi/mixins/_protocol.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/ytmusicapi/mixins/_protocol.py b/ytmusicapi/mixins/_protocol.py index 5620a761..c150f805 100644 --- a/ytmusicapi/mixins/_protocol.py +++ b/ytmusicapi/mixins/_protocol.py @@ -19,8 +19,6 @@ class MixinProtocol(Protocol): proxies: Optional[dict[str, str]] - context: dict - def _check_auth(self) -> None: """checks if self has authentication""" From fc1dec4f81284e13e9978e12a844e878da132251 Mon Sep 17 00:00:00 2001 From: henrich26 Date: Fri, 18 Oct 2024 18:23:47 +0200 Subject: [PATCH 09/14] fix some formatting complaints by ruff and mypy --- ytmusicapi/mixins/_protocol.py | 2 +- ytmusicapi/mixins/browsing.py | 8 ++++---- ytmusicapi/models/lyrics.py | 2 +- ytmusicapi/ytmusic.py | 5 ++--- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/ytmusicapi/mixins/_protocol.py b/ytmusicapi/mixins/_protocol.py index c150f805..28745239 100644 --- a/ytmusicapi/mixins/_protocol.py +++ b/ytmusicapi/mixins/_protocol.py @@ -1,7 +1,7 @@ """protocol that defines the functions available to mixins""" -from typing import Optional, Protocol from contextlib import contextmanager +from typing import Optional, Protocol from requests import Response from requests.structures import CaseInsensitiveDict diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index fdf94c57..c2390ea6 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -1,12 +1,13 @@ import re import warnings -from typing import Any, Optional, cast +from typing import Any, cast, Optional, Union from ytmusicapi.continuations import ( get_continuations, get_reloadable_continuation_params, ) from ytmusicapi.helpers import YTM_DOMAIN, sum_total_duration +from ytmusicapi.models.lyrics import Lyrics, LyricLine, TimedLyrics from ytmusicapi.parsers.albums import parse_album_header_2024 from ytmusicapi.parsers.browsing import ( parse_album, @@ -17,7 +18,6 @@ ) from ytmusicapi.parsers.library import parse_albums from ytmusicapi.parsers.playlists import parse_playlist_items -from ytmusicapi.models.lyrics import Lyrics, TimedLyrics, LyricLine from ..exceptions import YTMusicError, YTMusicUserError from ..navigation import * @@ -908,7 +908,7 @@ def get_lyrics(self, browseId: str, timestamps: Optional[bool] = False) -> Optio if timestamps and (data := nav(response, TIMESTAMPED_LYRICS, True)) is not None: assert isinstance(data, dict) - if not "timedLyricsData" in data: + if "timedLyricsData" not in data: return None lyrics["lyrics"] = list(map(LyricLine.from_raw, data["timedLyricsData"])) @@ -927,7 +927,7 @@ def get_lyrics(self, browseId: str, timestamps: Optional[bool] = False) -> Optio ) lyrics["hasTimestamps"] = False - return cast(Lyrics | TimedLyrics, lyrics) + return cast(Union[Lyrics, TimedLyrics], lyrics) def get_basejs_url(self) -> str: """ diff --git a/ytmusicapi/models/lyrics.py b/ytmusicapi/models/lyrics.py index e97198dd..bab2742c 100644 --- a/ytmusicapi/models/lyrics.py +++ b/ytmusicapi/models/lyrics.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import TypedDict, Optional, Literal +from typing import Literal, Optional, TypedDict @dataclass diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 4d836617..77e96c78 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -2,11 +2,10 @@ import json import locale import time -from contextlib import suppress +from contextlib import suppress, contextmanager from functools import partial from pathlib import Path -from typing import Optional, Union, cast -from contextlib import contextmanager +from typing import cast, Optional, Union import requests from requests import Response From 347c2e64bd99d0db708ad164a8ee22a84bbd96b9 Mon Sep 17 00:00:00 2001 From: henrich26 Date: Sun, 20 Oct 2024 13:39:35 +0200 Subject: [PATCH 10/14] please the linter --- tests/mixins/test_browsing.py | 2 +- ytmusicapi/mixins/_utils.py | 3 +-- ytmusicapi/mixins/browsing.py | 20 +++++++++----------- ytmusicapi/mixins/library.py | 4 +++- ytmusicapi/mixins/uploads.py | 8 ++++++-- ytmusicapi/models/lyrics.py | 3 ++- ytmusicapi/navigation.py | 12 ++++++++++-- ytmusicapi/ytmusic.py | 27 +++++++++++++-------------- 8 files changed, 45 insertions(+), 34 deletions(-) diff --git a/tests/mixins/test_browsing.py b/tests/mixins/test_browsing.py index 29945c57..22446525 100644 --- a/tests/mixins/test_browsing.py +++ b/tests/mixins/test_browsing.py @@ -181,7 +181,7 @@ def test_get_lyrics(self, config, yt, sample_video): assert lyrics_song["hasTimestamps"] is False # test lyrics with timestamps - lyrics_song = yt.get_lyrics(playlist["lyrics"], timestamps = True) + lyrics_song = yt.get_lyrics(playlist["lyrics"], timestamps=True) assert lyrics_song is not None assert len(lyrics_song["lyrics"]) >= 1 assert lyrics_song["hasTimestamps"] is True diff --git a/ytmusicapi/mixins/_utils.py b/ytmusicapi/mixins/_utils.py index cc8a5491..9a262db9 100644 --- a/ytmusicapi/mixins/_utils.py +++ b/ytmusicapi/mixins/_utils.py @@ -4,8 +4,7 @@ from ytmusicapi.exceptions import YTMusicUserError - -LibraryOrderType = Literal['a_to_z', 'z_to_a', 'recently_added'] +LibraryOrderType = Literal["a_to_z", "z_to_a", "recently_added"] def prepare_like_endpoint(rating): diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index c2390ea6..034b49a3 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -1,13 +1,13 @@ import re import warnings -from typing import Any, cast, Optional, Union +from typing import Any, Optional, Union, cast, overload from ytmusicapi.continuations import ( get_continuations, get_reloadable_continuation_params, ) from ytmusicapi.helpers import YTM_DOMAIN, sum_total_duration -from ytmusicapi.models.lyrics import Lyrics, LyricLine, TimedLyrics +from ytmusicapi.models.lyrics import LyricLine, Lyrics, TimedLyrics from ytmusicapi.parsers.albums import parse_album_header_2024 from ytmusicapi.parsers.browsing import ( parse_album, @@ -277,7 +277,7 @@ def get_artist(self, channelId: str) -> dict: artist.update(self.parser.parse_channel_contents(results)) return artist - ArtistOrderType = Literal['Recency', 'Popularity', 'Alphabetical order'] + ArtistOrderType = Literal["Recency", "Popularity", "Alphabetical order"] def get_artist_albums( self, channelId: str, params: str, limit: Optional[int] = 100, order: Optional[ArtistOrderType] = None @@ -839,26 +839,25 @@ def get_song_related(self, browseId: str): sections = nav(response, ["contents", *SECTION_LIST]) return parse_mixed_content(sections) - @overload def get_lyrics(self, browseId: str, timestamps: Literal[False] = False) -> Optional[Lyrics]: """overload for mypy only""" @overload - def get_lyrics(self, browseId: str, timestamps: Literal[True] = True) -> Optional[Lyrics|TimedLyrics]: + def get_lyrics(self, browseId: str, timestamps: Literal[True] = True) -> Optional[Lyrics | TimedLyrics]: """overload for mypy only""" - def get_lyrics(self, browseId: str, timestamps: Optional[bool] = False) -> Optional[Lyrics|TimedLyrics]: + def get_lyrics(self, browseId: str, timestamps: Optional[bool] = False) -> Optional[Lyrics | TimedLyrics]: """ Returns lyrics of a song or video. When `timestamps` is set, lyrics are returned with timestamps, if available. :param browseId: Lyrics browse-id obtained from :py:func:`get_watch_playlist` (startswith `MPLYt...`). :param timestamps: Optional. Whether to return bare lyrics or lyrics with timestamps, if available. (Default: `False`) - :return: Dictionary with song lyrics or `None`, if no lyrics are found. + :return: Dictionary with song lyrics or `None`, if no lyrics are found. The `hasTimestamps`-key determines the format of the data. - + Example when `timestamps=False`, or no timestamps are available:: { @@ -866,7 +865,7 @@ def get_lyrics(self, browseId: str, timestamps: Optional[bool] = False) -> Optio "source": "Source: LyricFind", "hasTimestamps": False } - + Example when `timestamps` is set to `True` and timestamps are available:: { @@ -892,8 +891,7 @@ def get_lyrics(self, browseId: str, timestamps: Optional[bool] = False) -> Optio lyrics: dict = {} if not browseId: - raise YTMusicUserError( - "Invalid browseId provided. This song might not have lyrics.") + raise YTMusicUserError("Invalid browseId provided. This song might not have lyrics.") if timestamps: # changes and restores the client to get lyrics with timestamps (mobile only) diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py index 22edd246..cf6d3a86 100644 --- a/ytmusicapi/mixins/library.py +++ b/ytmusicapi/mixins/library.py @@ -179,7 +179,9 @@ def get_library_artists(self, limit: int = 25, order: Optional[LibraryOrderType] response, lambda additionalParams: self._send_request(endpoint, body, additionalParams), limit ) - def get_library_subscriptions(self, limit: int = 25, order: Optional[LibraryOrderType] = None) -> list[dict]: + def get_library_subscriptions( + self, limit: int = 25, order: Optional[LibraryOrderType] = None + ) -> list[dict]: """ Gets the artists the user has subscribed to. diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py index 026d8fa7..7629bc5c 100644 --- a/ytmusicapi/mixins/uploads.py +++ b/ytmusicapi/mixins/uploads.py @@ -23,7 +23,9 @@ class UploadsMixin(MixinProtocol): - def get_library_upload_songs(self, limit: Optional[int] = 25, order: Optional[LibraryOrderType] = None) -> list[dict]: + def get_library_upload_songs( + self, limit: Optional[int] = 25, order: Optional[LibraryOrderType] = None + ) -> list[dict]: """ Returns a list of uploaded songs @@ -70,7 +72,9 @@ def get_library_upload_songs(self, limit: Optional[int] = 25, order: Optional[Li return songs - def get_library_upload_albums(self, limit: Optional[int] = 25, order: Optional[LibraryOrderType] = None) -> list[dict]: + def get_library_upload_albums( + self, limit: Optional[int] = 25, order: Optional[LibraryOrderType] = None + ) -> list[dict]: """ Gets the albums of uploaded songs in the user's library. diff --git a/ytmusicapi/models/lyrics.py b/ytmusicapi/models/lyrics.py index bab2742c..534c6916 100644 --- a/ytmusicapi/models/lyrics.py +++ b/ytmusicapi/models/lyrics.py @@ -5,13 +5,14 @@ @dataclass class LyricLine: """Represents a line of lyrics with timestamps (in milliseconds). - + Args: text (str): The Songtext. start_time (int): Begin of the lyric in milliseconds. end_time (int): End of the lyric in milliseconds. id (int): A Metadata-Id that probably uniquely identifies each lyric line. """ + text: str start_time: int end_time: int diff --git a/ytmusicapi/navigation.py b/ytmusicapi/navigation.py index d968b6ef..5ab6d7c8 100644 --- a/ytmusicapi/navigation.py +++ b/ytmusicapi/navigation.py @@ -88,8 +88,16 @@ CAROUSEL_TITLE = [*HEADER, "musicCarouselShelfBasicHeaderRenderer", *TITLE] CARD_SHELF_TITLE = [*HEADER, "musicCardShelfHeaderBasicRenderer", *TITLE_TEXT] FRAMEWORK_MUTATIONS = ["frameworkUpdates", "entityBatchUpdate", "mutations"] -TIMESTAMPED_LYRICS = ["contents", "elementRenderer", "newElement", "type", - "componentType", "model", "timedLyricsModel", "lyricsData"] +TIMESTAMPED_LYRICS = [ + "contents", + "elementRenderer", + "newElement", + "type", + "componentType", + "model", + "timedLyricsModel", + "lyricsData", +] @overload diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 77e96c78..4c06b0d1 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -2,10 +2,10 @@ import json import locale import time -from contextlib import suppress, contextmanager +from contextlib import contextmanager, suppress from functools import partial from pathlib import Path -from typing import cast, Optional, Union +from typing import Optional, Union, cast import requests from requests import Response @@ -189,14 +189,16 @@ def base_headers(self): if self.auth_type == AuthType.BROWSER or self.auth_type == AuthType.OAUTH_CUSTOM_FULL: self._base_headers = self._input_dict else: - self._base_headers = CaseInsensitiveDict({ - "user-agent": USER_AGENT, - "accept": "*/*", - "accept-encoding": "gzip, deflate", - "content-type": "application/json", - "content-encoding": "gzip", - "origin": YTM_DOMAIN, - }) + self._base_headers = CaseInsensitiveDict( + { + "user-agent": USER_AGENT, + "accept": "*/*", + "accept-encoding": "gzip, deflate", + "content-type": "application/json", + "content-encoding": "gzip", + "origin": YTM_DOMAIN, + } + ) return cast(CaseInsensitiveDict[str], self._base_headers) @@ -243,10 +245,7 @@ def as_mobile(self): # change the context to emulate a mobile-app (Android) copied_context_client = self.context["context"]["client"].copy() - self.context["context"]["client"].update({ - "clientName": "ANDROID_MUSIC", - "clientVersion": "7.21.50" - }) + self.context["context"]["client"].update({"clientName": "ANDROID_MUSIC", "clientVersion": "7.21.50"}) # this will not catch errors try: From b454614bb717a16b8e65e6bc0c2c22b333fd927b Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 17 Dec 2024 18:42:11 +0100 Subject: [PATCH 11/14] fix union syntax --- ytmusicapi/mixins/browsing.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index 034b49a3..8fc4a8aa 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -844,10 +844,14 @@ def get_lyrics(self, browseId: str, timestamps: Literal[False] = False) -> Optio """overload for mypy only""" @overload - def get_lyrics(self, browseId: str, timestamps: Literal[True] = True) -> Optional[Lyrics | TimedLyrics]: + def get_lyrics( + self, browseId: str, timestamps: Literal[True] = True + ) -> Optional[Union[Lyrics, TimedLyrics]]: """overload for mypy only""" - def get_lyrics(self, browseId: str, timestamps: Optional[bool] = False) -> Optional[Lyrics | TimedLyrics]: + def get_lyrics( + self, browseId: str, timestamps: Optional[bool] = False + ) -> Optional[Union[Lyrics, TimedLyrics]]: """ Returns lyrics of a song or video. When `timestamps` is set, lyrics are returned with timestamps, if available. From 2e31235cc0a41dcc2b67326f99b097ff212d4a00 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 17 Dec 2024 21:50:31 +0100 Subject: [PATCH 12/14] improve typing in ytmusic.py --- ytmusicapi/mixins/_protocol.py | 3 ++- ytmusicapi/ytmusic.py | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/ytmusicapi/mixins/_protocol.py b/ytmusicapi/mixins/_protocol.py index 28745239..0ae2b959 100644 --- a/ytmusicapi/mixins/_protocol.py +++ b/ytmusicapi/mixins/_protocol.py @@ -1,5 +1,6 @@ """protocol that defines the functions available to mixins""" +from collections.abc import Iterator from contextlib import contextmanager from typing import Optional, Protocol @@ -29,7 +30,7 @@ def _send_get_request(self, url: str, params: Optional[dict] = None) -> Response """for sending get requests to YouTube Music""" @contextmanager - def as_mobile(self): + def as_mobile(self) -> Iterator[None]: """context-manager, that allows requests as the YouTube Music Mobile-App""" @property diff --git a/ytmusicapi/ytmusic.py b/ytmusicapi/ytmusic.py index 4c06b0d1..91339812 100644 --- a/ytmusicapi/ytmusic.py +++ b/ytmusicapi/ytmusic.py @@ -2,10 +2,11 @@ import json import locale import time +from collections.abc import Iterator from contextlib import contextmanager, suppress from functools import partial from pathlib import Path -from typing import Optional, Union, cast +from typing import Optional, Union import requests from requests import Response @@ -89,8 +90,10 @@ def __init__( 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: Optional[CaseInsensitiveDict] = ( + None #: for authless initializing requests during OAuth flow + ) + self._headers: Optional[CaseInsensitiveDict] = None #: cache formed headers including auth self.auth = auth #: raw auth self._input_dict: CaseInsensitiveDict = ( @@ -179,12 +182,12 @@ def __init__( try: cookie = self.base_headers.get("cookie") self.sapisid = sapisid_from_cookie(cookie) - self.origin = cast(str, 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 YTMusicUserError("Your cookie is missing the required value __Secure-3PAPISID") @property - def base_headers(self): + def base_headers(self) -> CaseInsensitiveDict: if not self._base_headers: if self.auth_type == AuthType.BROWSER or self.auth_type == AuthType.OAUTH_CUSTOM_FULL: self._base_headers = self._input_dict @@ -200,10 +203,10 @@ def base_headers(self): } ) - return cast(CaseInsensitiveDict[str], self._base_headers) + return self._base_headers @property - def headers(self): + def headers(self) -> CaseInsensitiveDict: # set on first use if not self._headers: self._headers = self.base_headers @@ -221,7 +224,7 @@ def headers(self): return self._headers @contextmanager - def as_mobile(self): + def as_mobile(self) -> Iterator[None]: """ Not thread-safe! ---------------- From fc17e932b8a04d8bb3a1cfffc0c53d2724de8876 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 17 Dec 2024 22:00:47 +0100 Subject: [PATCH 13/14] improve typing --- ytmusicapi/mixins/browsing.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py index d643c3f5..b96ce4f5 100644 --- a/ytmusicapi/mixins/browsing.py +++ b/ytmusicapi/mixins/browsing.py @@ -892,8 +892,6 @@ def get_lyrics( } """ - - lyrics: dict = {} if not browseId: raise YTMusicUserError("Invalid browseId provided. This song might not have lyrics.") @@ -905,29 +903,32 @@ def get_lyrics( response = self._send_request("browse", {"browseId": browseId}) # unpack the response - - # we got lyrics with timestamps + lyrics: Union[Lyrics, TimedLyrics] if timestamps and (data := nav(response, TIMESTAMPED_LYRICS, True)) is not None: + # we got lyrics with timestamps assert isinstance(data, dict) - if "timedLyricsData" not in data: + if "timedLyricsData" not in data: # pragma: no cover return None - lyrics["lyrics"] = list(map(LyricLine.from_raw, data["timedLyricsData"])) - lyrics["source"] = data.get("sourceMessage") - lyrics["hasTimestamps"] = True + lyrics = TimedLyrics( + lyrics=list(map(LyricLine.from_raw, data["timedLyricsData"])), + source=data.get("sourceMessage"), + hasTimestamps=True, + ) else: - lyrics["lyrics"] = nav( + lyrics_str = nav( response, ["contents", *SECTION_LIST_ITEM, *DESCRIPTION_SHELF, *DESCRIPTION], True ) - if lyrics["lyrics"] is None: + if lyrics_str is None: # pragma: no cover return None - lyrics["source"] = nav( - response, ["contents", *SECTION_LIST_ITEM, *DESCRIPTION_SHELF, "footer", *RUN_TEXT], True + lyrics = Lyrics( + lyrics=lyrics_str, + source=nav(response, ["contents", *SECTION_LIST_ITEM, *DESCRIPTION_SHELF, *RUN_TEXT], True), + hasTimestamps=False, ) - lyrics["hasTimestamps"] = False return cast(Union[Lyrics, TimedLyrics], lyrics) From a5900a0bc30388169a653e61a32d828c6afea1b8 Mon Sep 17 00:00:00 2001 From: sigma67 Date: Tue, 17 Dec 2024 22:00:58 +0100 Subject: [PATCH 14/14] replace docsbuild.yml with RTD --- .github/workflows/docsbuild.yml | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 .github/workflows/docsbuild.yml diff --git a/.github/workflows/docsbuild.yml b/.github/workflows/docsbuild.yml deleted file mode 100644 index 371abb0f..00000000 --- a/.github/workflows/docsbuild.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Build Documentation - -on: - push: - branches: - - main - paths: - - ytmusicapi/** - - docs/** - pull_request: - branches: - - main - paths: - - ytmusicapi/** - - docs/** - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install . sphinx sphinx-rtd-theme - - name: Build documentation - run: | - cd docs - make html