From 3198b1fb9c6e04f518025d81054113bb1d23f855 Mon Sep 17 00:00:00 2001 From: henrich26 Date: Wed, 16 Oct 2024 23:33:31 +0200 Subject: [PATCH] fixed remaining issues --- tests/mixins/test_browsing.py | 7 +- ytmusicapi/__init__.py | 4 +- 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 ++++++++ 11 files changed, 127 insertions(+), 173 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 3526068e..86338039 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: @@ -174,15 +174,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/__init__.py b/ytmusicapi/__init__.py index 6bac87de..09986e19 100644 --- a/ytmusicapi/__init__.py +++ b/ytmusicapi/__init__.py @@ -2,7 +2,6 @@ from ytmusicapi.setup import setup, setup_oauth from ytmusicapi.ytmusic import YTMusic -from .mixins.browsing import Lyrics, TimedLyrics, LyricLine try: __version__ = version("ytmusicapi") @@ -13,5 +12,4 @@ __copyright__ = "Copyright 2024 sigma67" __license__ = "MIT" __title__ = "ytmusicapi" -__all__ = ["YTMusic", "setup_oauth", "setup", - "Lyrics", "TimedLyrics", "LyricLine"] +__all__ = ["YTMusic", "setup_oauth", "setup"] 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 02724b27..bed44302 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 1b640198..c225f242 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 @@ -220,6 +221,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)