Skip to content

Commit

Permalink
Added Lyrics w. Timestamps #662 (#693)
Browse files Browse the repository at this point in the history
* 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)

* Update browsing.py

* Combined both get_lyrics methods into one and fixed some typechecking errors

* Fixed a missing hasTimestamps and added the doccomments to the overloads, because vscode didn't show them otherwise

* Removed the old get_lyrics_with_timestamps method

because idk where it came from...

* fixed remaining issues

* Update uploads.py

reverted some changes that I moved to an extra PR

* removed variable `context` from the Mixin, as it's use was replaced by `yt.as_mobile()`

* fix some formatting complaints by ruff and mypy

* please the linter

* fix union syntax

* improve typing in ytmusic.py

* improve typing

* replace docsbuild.yml with RTD

---------

Co-authored-by: Hendrik Horstmann <[email protected]>
Co-authored-by: henrich26 <[email protected]>
  • Loading branch information
3 people authored Dec 17, 2024
1 parent bb4bf4d commit b86654f
Show file tree
Hide file tree
Showing 12 changed files with 246 additions and 82 deletions.
33 changes: 0 additions & 33 deletions .github/workflows/docsbuild.yml

This file was deleted.

20 changes: 18 additions & 2 deletions tests/mixins/test_browsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pytest

from tests.test_helpers import is_ci
from ytmusicapi.models.lyrics import LyricLine


class TestBrowsing:
Expand Down Expand Up @@ -173,9 +174,24 @@ 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 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 song.start_time <= song.end_time
assert isinstance(song.id, int)

playlist = yt.get_watch_playlist(config["uploads"]["private_upload_id"])
assert playlist["lyrics"] is None
Expand Down
2 changes: 1 addition & 1 deletion ytmusicapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
9 changes: 8 additions & 1 deletion ytmusicapi/mixins/_protocol.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
"""protocol that defines the functions available to mixins"""

from collections.abc import Iterator
from contextlib import contextmanager
from typing import Optional, Protocol

from requests import Response
from requests.structures import CaseInsensitiveDict

from ytmusicapi.auth.types import AuthType
from ytmusicapi.parsers.i18n import Parser
Expand All @@ -26,6 +29,10 @@ def _send_request(self, endpoint: str, body: dict, additionalParams: str = "") -
def _send_get_request(self, url: str, params: Optional[dict] = None) -> Response:
"""for sending get requests to YouTube Music"""

@contextmanager
def as_mobile(self) -> Iterator[None]:
"""context-manager, that allows requests as the YouTube Music Mobile-App"""

@property
def headers(self) -> dict[str, str]:
def headers(self) -> CaseInsensitiveDict[str]:
"""property for getting request headers"""
5 changes: 4 additions & 1 deletion ytmusicapi/mixins/_utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import re
from datetime import date
from typing import Literal

from ytmusicapi.exceptions import YTMusicUserError

LibraryOrderType = Literal["a_to_z", "z_to_a", "recently_added"]


def prepare_like_endpoint(rating):
if rating == "LIKE":
Expand All @@ -24,7 +27,7 @@ def validate_order_parameter(order):
)


def prepare_order_params(order):
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
Expand Down
109 changes: 88 additions & 21 deletions ytmusicapi/mixins/browsing.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import re
import warnings
from typing import Any, Optional
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 LyricLine, Lyrics, TimedLyrics
from ytmusicapi.parsers.albums import parse_album_header_2024
from ytmusicapi.parsers.browsing import (
parse_album,
Expand Down Expand Up @@ -276,8 +277,10 @@ def get_artist(self, channelId: str) -> dict:
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
Expand Down Expand Up @@ -836,36 +839,100 @@ 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[Union[Lyrics, TimedLyrics]]:
"""overload for mypy only"""

def get_lyrics(
self, browseId: str, timestamps: Optional[bool] = False
) -> Optional[Union[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 browseId 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::
{
"lyrics": "Today is gonna be the day\\nThat they're gonna throw it back to you\\n",
"source": "Source: LyricFind"
}
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",
"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 = {}
if not browseId:
raise YTMusicUserError("Invalid browseId provided. This song might not have lyrics.")

response = self._send_request("browse", {"browseId": browseId})
lyrics["lyrics"] = nav(
response, ["contents", *SECTION_LIST_ITEM, *DESCRIPTION_SHELF, *DESCRIPTION], True
)
lyrics["source"] = nav(
response, ["contents", *SECTION_LIST_ITEM, *DESCRIPTION_SHELF, "footer", *RUN_TEXT], True
)
if timestamps:
# 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
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: # pragma: no cover
return None

lyrics = TimedLyrics(
lyrics=list(map(LyricLine.from_raw, data["timedLyricsData"])),
source=data.get("sourceMessage"),
hasTimestamps=True,
)
else:
lyrics_str = nav(
response, ["contents", *SECTION_LIST_ITEM, *DESCRIPTION_SHELF, *DESCRIPTION], True
)

if lyrics_str is None: # pragma: no cover
return None

lyrics = Lyrics(
lyrics=lyrics_str,
source=nav(response, ["contents", *SECTION_LIST_ITEM, *DESCRIPTION_SHELF, *RUN_TEXT], True),
hasTimestamps=False,
)

return lyrics
return cast(Union[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.
Expand Down
14 changes: 8 additions & 6 deletions ytmusicapi/mixins/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -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[LibraryOrderType] = None
) -> list[dict]:
"""
Gets the songs in the user's library (liked videos are not included).
Expand Down Expand Up @@ -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[LibraryOrderType] = None) -> list[dict]:
"""
Gets the albums in the user's library.
Expand Down Expand Up @@ -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[LibraryOrderType] = None) -> list[dict]:
"""
Gets the artists of the songs in the user's library.
Expand Down Expand Up @@ -179,7 +179,9 @@ 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[LibraryOrderType] = None
) -> list[dict]:
"""
Gets the artists the user has subscribed to.
Expand All @@ -198,7 +200,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[LibraryOrderType] = None) -> list[dict]:
"""
Get podcasts the user has added to the library
Expand Down Expand Up @@ -244,7 +246,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[LibraryOrderType] = None) -> list[dict]:
"""
Get channels the user has added to the library
Expand Down
12 changes: 8 additions & 4 deletions ytmusicapi/mixins/uploads.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
from ..enums import ResponseStatus
from ..exceptions import YTMusicUserError
from ._protocol import MixinProtocol
from ._utils import 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[str] = 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
Expand Down Expand Up @@ -70,7 +72,9 @@ 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[LibraryOrderType] = None
) -> list[dict]:
"""
Gets the albums of uploaded songs in the user's library.
Expand All @@ -90,7 +94,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[LibraryOrderType] = None
) -> list[dict]:
"""
Gets the artists of uploaded songs in the user's library.
Expand Down
3 changes: 3 additions & 0 deletions ytmusicapi/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .lyrics import LyricLine, Lyrics, TimedLyrics

__all__ = ["LyricLine", "Lyrics", "TimedLyrics"]
Loading

0 comments on commit b86654f

Please sign in to comment.