diff --git a/tests/mixins/test_browsing.py b/tests/mixins/test_browsing.py index 0c530a14..3526068e 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: @@ -164,9 +165,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 4f3ce510..6bac87de 100644 --- a/ytmusicapi/__init__.py +++ b/ytmusicapi/__init__.py @@ -2,6 +2,7 @@ from ytmusicapi.setup import setup, setup_oauth from ytmusicapi.ytmusic import YTMusic +from .mixins.browsing import Lyrics, TimedLyrics, LyricLine try: __version__ = version("ytmusicapi") @@ -9,7 +10,8 @@ # package is not installed pass -__copyright__ = "Copyright 2023 sigma67" +__copyright__ = "Copyright 2024 sigma67" __license__ = "MIT" __title__ = "ytmusicapi" -__all__ = ["YTMusic", "setup_oauth", "setup"] +__all__ = ["YTMusic", "setup_oauth", "setup", + "Lyrics", "TimedLyrics", "LyricLine"] 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..3bf71139 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,180 @@ 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]: """ - 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 = {} + + @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:: + + { + "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]: + """ + 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 + } + + """ + + 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 + ) + lyrics["hasTimestamps"] = False + + return cast(Lyrics | TimedLyrics, lyrics) def get_lyrics_with_timestamps(self, browseId: str) -> dict: """ @@ -935,7 +1127,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 4cdbc5c0..02724b27 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 2cccae95..1b640198 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 @@ -181,7 +181,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") @@ -191,16 +191,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):