Skip to content

Commit 96637b8

Browse files
authored
feat: add ytmusic song lyrics support
Add song lyrics support using timestamped YTMusic lyrics, convert lyrics to LRC, and isolate lyrics parsing into a dedicated module.
1 parent 77c1c24 commit 96637b8

7 files changed

Lines changed: 575 additions & 7 deletions

File tree

fuo_ytmusic/lyrics.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from typing import Optional
2+
3+
WATCH_NEXT_RENDERER_PATH = (
4+
"contents",
5+
"singleColumnMusicWatchNextResultsRenderer",
6+
"tabbedRenderer",
7+
"watchNextTabbedResultsRenderer",
8+
)
9+
10+
11+
def build_watch_playlist_body(video_id: str) -> dict:
12+
return {
13+
"enablePersistentPlaylistPanel": True,
14+
"isAudioOnly": True,
15+
"tunerSettingValue": "AUTOMIX_SETTING_NORMAL",
16+
"videoId": video_id,
17+
"playlistId": f"RDAMVM{video_id}",
18+
"watchEndpointMusicSupportedConfigs": {
19+
"watchEndpointMusicConfig": {
20+
"hasPersistentPlaylistPanel": True,
21+
"musicVideoType": "MUSIC_VIDEO_TYPE_ATV",
22+
}
23+
},
24+
}
25+
26+
27+
def extract_lyrics_browse_id(watch_response) -> Optional[str]:
28+
tabs = watch_response
29+
for key in WATCH_NEXT_RENDERER_PATH:
30+
tabs = tabs[key]
31+
tabs = tabs["tabs"]
32+
33+
try:
34+
browse_id = tabs[1]["tabRenderer"]["endpoint"]["browseEndpoint"]["browseId"]
35+
except (IndexError, KeyError, TypeError):
36+
return None
37+
if not isinstance(browse_id, str):
38+
raise TypeError(f"unexpected lyrics browse id type: {type(browse_id)}")
39+
return browse_id
40+
41+
42+
def timestamped_lyrics_to_lrc(lyrics_payload) -> Optional[str]:
43+
lyrics = lyrics_payload["lyrics"]
44+
if not isinstance(lyrics, list):
45+
return None
46+
47+
lines = [format_lyric_line(line) for line in lyrics]
48+
if not lines:
49+
return None
50+
return "\n".join(lines)
51+
52+
53+
def format_lyric_line(line) -> str:
54+
return f"{format_lrc_timestamp(line.start_time)}{line.text}"
55+
56+
57+
def format_lrc_timestamp(milliseconds: int) -> str:
58+
if not isinstance(milliseconds, int):
59+
raise TypeError(f"unexpected lyric start_time type: {type(milliseconds)}")
60+
total_ms = max(0, milliseconds)
61+
minutes = total_ms // 60000
62+
seconds = (total_ms % 60000) // 1000
63+
centiseconds = (total_ms % 1000) // 10
64+
return f"[{minutes:02d}:{seconds:02d}.{centiseconds:02d}]"

fuo_ytmusic/provider.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
BriefVideoModel,
1111
Collection,
1212
CollectionType,
13+
LyricModel,
1314
ModelNotFound,
1415
PlaylistModel,
1516
ProviderV2,
@@ -118,7 +119,9 @@ def _get_ytdlp_cookiefile_path(self) -> str:
118119
return ""
119120
if not self._should_use_ytdlp_cookiefile():
120121
return ""
121-
headerfile_path = getattr(self.service.api, "headerfile_path", None) or HEADER_FILE
122+
headerfile_path = (
123+
getattr(self.service.api, "headerfile_path", None) or HEADER_FILE
124+
)
122125
cookiefile_path = YtdlpCookiefileManager(headerfile_path).cookiefile_path
123126
return "" if cookiefile_path is None else str(cookiefile_path)
124127

@@ -472,13 +475,29 @@ def song_get(self, identifier):
472475
# hack(cosven): we use get_watch_playlist to try to get song detail.
473476
# It works for song like '如愿-王菲'.
474477
result = self.service.api.get_watch_playlist(identifier)
475-
songs = [YtmusicWatchPlaylistSong(**track).v2_model() for track in result["tracks"]]
478+
songs = [
479+
YtmusicWatchPlaylistSong(**track).v2_model() for track in result["tracks"]
480+
]
476481
for song in songs:
477482
if song.identifier == identifier:
478483
return song
479484
# I think this branch should not be reached (in most cases).
480485
return ModelNotFound(f"song:{identifier} not found")
481486

487+
def song_get_lyric(self, song: BriefSongProtocol) -> Optional[LyricModel]:
488+
try:
489+
content = self.service.song_lyrics(song.identifier)
490+
except Exception as e:
491+
logger.warning("fetch ytmusic lyrics failed for %s: %s", song.identifier, e)
492+
raise ProviderIOError(f"get song lyric failed: {e}", provider=self)
493+
if not content:
494+
return None
495+
return LyricModel(
496+
identifier=song.identifier,
497+
source=self.meta.identifier,
498+
content=content,
499+
)
500+
482501
def song_list_similar(self, song):
483502
result = self.service.api.get_watch_playlist(song.identifier)
484503
songs = [

fuo_ytmusic/service.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
update_headerfile_cookie,
2121
)
2222
from fuo_ytmusic.helpers import Singleton
23+
from fuo_ytmusic.lyrics import (
24+
build_watch_playlist_body,
25+
extract_lyrics_browse_id,
26+
timestamped_lyrics_to_lrc,
27+
)
2328
from fuo_ytmusic.models import (
2429
AlbumInfo,
2530
ArtistInfo,
@@ -216,10 +221,13 @@ class YtmusicService(metaclass=Singleton):
216221
def __init__(self):
217222
self._session = requests.Session()
218223
self._api: Optional[YTMusic] = None
224+
self._anonymous_lyrics_api: Optional[YTMusic] = None
225+
self._timeout: Optional[int] = None
219226
self._session.hooks["response"].append(self._do_logging)
220227

221228
self._signature_timestamp = 0
222229
self._api_lock = threading.Lock()
230+
self._anonymous_lyrics_api_lock = threading.Lock()
223231
self._profile_manager = YtmusicProfileManager(self)
224232
self._language: Optional[str] = None
225233

@@ -245,9 +253,12 @@ def get_signature_timestamp(self):
245253
return 0
246254

247255
def reinitialize_by_headerfile(self, headerfile=None):
256+
self._api = self._create_api(headerfile, self._session)
257+
258+
def _create_api(self, headerfile, session: requests.Session) -> YTMusic:
248259
language = self._language or "zh_CN"
249260
options = dict(
250-
requests_session=self._session,
261+
requests_session=session,
251262
language=language,
252263
oauth_credentials=OAuthCredentials(
253264
# In the new version of ytmusicapi, client_id and client_secret
@@ -259,35 +270,62 @@ def reinitialize_by_headerfile(self, headerfile=None):
259270
".apps.googleusercontent.com"
260271
),
261272
client_secret="SboVhoG9s0rNafixCSGGKXAT",
262-
session=self._session,
273+
session=session,
263274
),
264275
)
265276
# Due to https://github.com/sigma67/ytmusicapi/issues/676,
266277
# YTMusic does not work in specific cases when auth file is provided.
267278
# So initialize without auth file when 400 is returned.
268279
if headerfile is not None and headerfile.exists():
269280
logger.info("Initializing ytmusic api with headerfile.")
270-
self._api = YTMusic(str(headerfile), **options)
271-
self._api.set_headerfile_path(headerfile)
281+
api = YTMusic(str(headerfile), **options)
282+
api.set_headerfile_path(headerfile)
272283
else:
273284
logger.info("Initializing ytmusic api with no headerfile.")
274-
self._api = YTMusic(**options)
285+
api = YTMusic(**options)
286+
return api
287+
288+
def _get_anonymous_lyrics_api(self) -> YTMusic:
289+
if self._anonymous_lyrics_api is None:
290+
with self._anonymous_lyrics_api_lock:
291+
if self._anonymous_lyrics_api is None:
292+
# Headerfile-authenticated clients can get HTTP 400 when
293+
# requesting timestamped lyrics, while anonymous requests
294+
# for the same lyrics browse id succeed.
295+
session = requests.Session()
296+
session.hooks["response"].append(self._do_logging)
297+
session.proxies = dict(self._session.proxies)
298+
if self._timeout is not None:
299+
session.request = partial(
300+
session.request,
301+
timeout=self._timeout,
302+
)
303+
self._anonymous_lyrics_api = self._create_api(None, session)
304+
return self._anonymous_lyrics_api
305+
306+
def _clear_anonymous_lyrics_api(self):
307+
with self._anonymous_lyrics_api_lock:
308+
self._anonymous_lyrics_api = None
275309

276310
def setup_language(self, language: str):
277311
self._language = language
312+
self._clear_anonymous_lyrics_api()
278313

279314
def setup_http_proxy(self, http_proxy):
280315
self._session.proxies = {
281316
"http": http_proxy,
282317
"https": http_proxy,
283318
}
319+
self._clear_anonymous_lyrics_api()
284320

285321
def setup_timeout(self, timeout):
322+
self._timeout = timeout
286323
if isinstance(self._session.request, partial):
287324
request = self._session.request.func
288325
else:
289326
request = self._session.request
290327
self._session.request = partial(request, timeout=timeout)
328+
self._clear_anonymous_lyrics_api()
291329

292330
def search(
293331
self,
@@ -365,6 +403,20 @@ def album_info(self, browse_id: str) -> AlbumInfo:
365403
def song_info(self, video_id: str) -> SongInfo:
366404
return SongInfo(**self.api.get_song(video_id, self.get_signature_timestamp()))
367405

406+
def _song_lyrics_browse_id(self, video_id: str, api=None) -> Optional[str]:
407+
if api is None:
408+
api = self.api
409+
response = api.send_api_request("next", build_watch_playlist_body(video_id))
410+
return extract_lyrics_browse_id(response)
411+
412+
def song_lyrics(self, video_id: str) -> Optional[str]:
413+
lyrics_api = self._get_anonymous_lyrics_api()
414+
lyrics_browse_id = self._song_lyrics_browse_id(video_id, api=lyrics_api)
415+
if lyrics_browse_id is None:
416+
return None
417+
lyrics_payload = lyrics_api.get_lyrics(lyrics_browse_id, timestamps=True)
418+
return timestamped_lyrics_to_lrc(lyrics_payload)
419+
368420
@ttl_cache(maxsize=CACHE_SIZE, ttl=CACHE_TTL)
369421
def categories(self) -> List[Categories]:
370422
return [

manual_tests/lyrics_e2e_test.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Manual end-to-end test for YTMusic timestamped lyrics.
2+
3+
Run with:
4+
uv run pytest manual_tests/lyrics_e2e_test.py -s --run-manual-tests
5+
6+
Optional environment variables:
7+
YTMUSIC_MANUAL_PROXY HTTP proxy url, e.g. http://127.0.0.1:7890
8+
YTMUSIC_MANUAL_TIMEOUT socket timeout in seconds (default: 8)
9+
YTMUSIC_MANUAL_LYRIC_SONG_IDS comma-separated song ids (default: tn7rzN8ABuo)
10+
YTMUSIC_MANUAL_USE_HEADERFILE set to 1 to authenticate with ytmusic_header.json
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import hashlib
16+
import os
17+
import socket
18+
from types import SimpleNamespace
19+
20+
import pytest
21+
from feeluown.player.lyric import parse_lyric_text
22+
23+
from fuo_ytmusic.consts import HEADER_FILE
24+
from fuo_ytmusic.provider import provider
25+
26+
27+
def _env_int(name: str, default: int) -> int:
28+
value = os.getenv(name, "").strip()
29+
if not value:
30+
return default
31+
try:
32+
return int(value)
33+
except ValueError:
34+
return default
35+
36+
37+
def _env_bool(name: str) -> bool:
38+
return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"}
39+
40+
41+
def _env_song_ids() -> list[str]:
42+
value = os.getenv("YTMUSIC_MANUAL_LYRIC_SONG_IDS", "").strip()
43+
if not value:
44+
return ["tn7rzN8ABuo"]
45+
return [part.strip() for part in value.split(",") if part.strip()]
46+
47+
48+
def _env_proxy() -> str:
49+
return (
50+
os.getenv("YTMUSIC_MANUAL_PROXY", "").strip()
51+
or os.getenv("HTTP_PROXY", "").strip()
52+
or os.getenv("http_proxy", "").strip()
53+
)
54+
55+
56+
def _setup_provider(timeout: int, proxy: str):
57+
socket.setdefaulttimeout(timeout)
58+
if proxy:
59+
provider.setup_http_proxy(proxy)
60+
provider.setup_http_timeout(timeout)
61+
62+
if not _env_bool("YTMUSIC_MANUAL_USE_HEADERFILE"):
63+
print("running anonymously")
64+
return
65+
66+
if not HEADER_FILE.exists():
67+
print(f"headerfile not found, running anonymously: {HEADER_FILE}")
68+
return
69+
70+
user = provider.try_get_user_with_headerfile()
71+
if user is None:
72+
print("auto login failed, running anonymously")
73+
return
74+
provider.auth(user)
75+
76+
77+
@pytest.mark.manual
78+
def test_song_get_lyric_end_to_end():
79+
timeout = _env_int("YTMUSIC_MANUAL_TIMEOUT", 8)
80+
proxy = _env_proxy()
81+
song_ids = _env_song_ids()
82+
83+
_setup_provider(timeout, proxy)
84+
print(
85+
f"manual lyrics config: timeout={timeout}, proxy={'set' if proxy else 'unset'}"
86+
)
87+
88+
for song_id in song_ids:
89+
print(f"\n=== song={song_id} ===")
90+
lyric = provider.song_get_lyric(SimpleNamespace(identifier=song_id))
91+
if lyric is None:
92+
pytest.fail(f"no timestamped lyrics returned for {song_id}")
93+
94+
parsed = parse_lyric_text(lyric.content)
95+
assert parsed, "lyric content should be parseable by FeelUOwn as LRC"
96+
print(f"timestamped lines: {len(parsed)}")
97+
summaries = []
98+
for line in lyric.content.splitlines()[:10]:
99+
timestamp = line.split("]", 1)[0] + "]" if "]" in line else ""
100+
digest = hashlib.sha1(line.encode()).hexdigest()[:10]
101+
summaries.append((timestamp, len(line), digest))
102+
print(f"first 10 line summaries: {summaries}")
103+
print(f"first 10 parsed timestamps: {list(parsed.keys())[:10]}")

0 commit comments

Comments
 (0)