Repository: sigma67/ytmusicapi
Pull request #693 (2024-12-17 17:39:23)

Added Lyrics w. Timestamps #662
copy of #662


Patch:
diff --git a/.github/workflows/docsbuild.yml b/.github/workflows/docsbuild.yml
deleted file mode 100644
index 371abb0..0000000
--- 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
diff --git a/ytmusicapi/__init__.py b/ytmusicapi/__init__.py
index 4ed851d..83bc676 100644
--- a/ytmusicapi/__init__.py
+++ b/ytmusicapi/__init__.py
@@ -9,7 +9,7 @@ except PackageNotFoundError:
     # 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 9b8aeb0..0ae2b95 100644
--- a/ytmusicapi/mixins/_protocol.py
+++ b/ytmusicapi/mixins/_protocol.py
@@ -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
@@ -26,6 +29,10 @@ class MixinProtocol(Protocol):
     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"""
diff --git a/ytmusicapi/mixins/_utils.py b/ytmusicapi/mixins/_utils.py
index 530015f..9a262db 100644
--- a/ytmusicapi/mixins/_utils.py
+++ b/ytmusicapi/mixins/_utils.py
@@ -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":
@@ -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
diff --git a/ytmusicapi/mixins/browsing.py b/ytmusicapi/mixins/browsing.py
index 477c1ec..b96ce4f 100644
--- a/ytmusicapi/mixins/browsing.py
+++ b/ytmusicapi/mixins/browsing.py
@@ -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,
@@ -276,8 +277,10 @@ class BrowsingMixin(MixinProtocol):
         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,36 +839,100 @@ class BrowsingMixin(MixinProtocol):
         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.
 
diff --git a/ytmusicapi/mixins/library.py b/ytmusicapi/mixins/library.py
index c82cde2..0517c96 100644
--- a/ytmusicapi/mixins/library.py
+++ b/ytmusicapi/mixins/library.py
@@ -46,7 +46,7 @@ class LibraryMixin(MixinProtocol):
         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).
@@ -116,7 +116,7 @@ class LibraryMixin(MixinProtocol):
 
         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.
 
@@ -151,7 +151,7 @@ class LibraryMixin(MixinProtocol):
             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.
 
@@ -179,7 +179,9 @@ class LibraryMixin(MixinProtocol):
             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.
 
@@ -198,7 +200,7 @@ class LibraryMixin(MixinProtocol):
             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
 
@@ -244,7 +246,7 @@ class LibraryMixin(MixinProtocol):
             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
 
diff --git a/ytmusicapi/mixins/uploads.py b/ytmusicapi/mixins/uploads.py
index feafbbd..84afe6d 100644
--- a/ytmusicapi/mixins/uploads.py
+++ b/ytmusicapi/mixins/uploads.py
@@ -19,11 +19,13 @@ from ..auth.types import AuthType
 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
 
@@ -70,7 +72,9 @@ class UploadsMixin(MixinProtocol):
 
         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.
 
@@ -90,7 +94,7 @@ class UploadsMixin(MixinProtocol):
         )
 
     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.
diff --git a/ytmusicapi/models/__init__.py b/ytmusicapi/models/__init__.py
new file mode 100644
index 0000000..3cc09e5
--- /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 0000000..534c691
--- /dev/null
+++ b/ytmusicapi/models/lyrics.py
@@ -0,0 +1,46 @@
+from dataclasses import dataclass
+from typing import Literal, Optional, TypedDict
+
+
+@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/navigation.py b/ytmusicapi/navigation.py
index 7bf89fa..5ab6d7c 100644
--- a/ytmusicapi/navigation.py
+++ b/ytmusicapi/navigation.py
@@ -88,6 +88,16 @@ CAROUSEL_CONTENTS = [*CAROUSEL, "contents"]
 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 9847da6..9133981 100644
--- a/ytmusicapi/ytmusic.py
+++ b/ytmusicapi/ytmusic.py
@@ -2,7 +2,8 @@ import gettext
 import json
 import locale
 import time
-from contextlib import suppress
+from collections.abc import Iterator
+from contextlib import contextmanager, suppress
 from functools import partial
 from pathlib import Path
 from typing import Optional, Union
@@ -89,8 +90,10 @@ class YTMusicBase:
             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 = (
@@ -184,24 +187,26 @@ class YTMusicBase:
                 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
             else:
-                self._base_headers = {
-                    "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 self._base_headers
 
     @property
-    def headers(self):
+    def headers(self) -> CaseInsensitiveDict:
         # set on first use
         if not self._headers:
             self._headers = self.base_headers
@@ -218,6 +223,40 @@ class YTMusicBase:
 
         return self._headers
 
+    @contextmanager
+    def as_mobile(self) -> Iterator[None]:
+        """
+        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)
 


Test patch:
diff --git a/tests/mixins/test_browsing.py b/tests/mixins/test_browsing.py
index 83c9909..2244652 100644
--- a/tests/mixins/test_browsing.py
+++ b/tests/mixins/test_browsing.py
@@ -6,6 +6,7 @@ from unittest import mock
 import pytest
 
 from tests.test_helpers import is_ci
+from ytmusicapi.models.lyrics import LyricLine
 
 
 class TestBrowsing:
@@ -173,9 +174,24 @@ class TestBrowsing:
 
     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