From 2d0c523f39fd446a5d35d7e08137a1e7c3856d56 Mon Sep 17 00:00:00 2001 From: Mohammad Momeni Date: Fri, 15 Mar 2024 12:08:15 +0100 Subject: [PATCH] Add enhancedLRC support (#24) Adds support for enhanced (word-level) synced lyrics using Musixmatch API. Resolved #24 --- README.md | 1 + syncedlyrics/__init__.py | 13 ++++++++++++- syncedlyrics/cli.py | 12 +++++++++++- syncedlyrics/providers/musixmatch.py | 24 +++++++++++++++++++++--- syncedlyrics/utils.py | 8 ++++++++ tests.py | 4 ++++ 6 files changed, 57 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 83e9ae0..7580c87 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ syncedlyrics "SEARCH_TERM" | `-l` | Language code of the translation ([ISO 639-1](https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes) format) | | `-v` | Use this flag to show the logs | | `--allow-plain` | Return a plain text (not synced) lyrics if no LRC format was found | +| `--enhanced` | Return an [Enhanced](https://en.wikipedia.org/wiki/LRC_(file_format)#A2_extension:_word_time_tag) (word-level karaoke) format ### Python ```py diff --git a/syncedlyrics/__init__.py b/syncedlyrics/__init__.py index cc689d3..72840d6 100644 --- a/syncedlyrics/__init__.py +++ b/syncedlyrics/__init__.py @@ -22,6 +22,7 @@ def search( save_path: Optional[str] = None, providers: Optional[List[str]] = None, lang: Optional[str] = None, + enhanced: bool = False, ) -> Optional[str]: """ Returns the synced lyrics of the song in [LRC](https://en.wikipedia.org/wiki/LRC_(file_format)) format if found. @@ -31,8 +32,15 @@ def search( - `save_path`: Path to save `.lrc` lyrics. No saving if `None` - `providers`: A list of provider names to include in searching; loops over all the providers as soon as an LRC is found - `lang`: Language of the translation along with the lyrics. **Only supported by Musixmatch** + - `enhanced`: Returns word by word synced lyrics if available. **Only supported by Musixmatch** """ - _providers = [Musixmatch(lang=lang), Lrclib(), Deezer(), NetEase(), Megalobiz()] + _providers = [ + Musixmatch(lang=lang, enhanced=enhanced), + Lrclib(), + Deezer(), + NetEase(), + Megalobiz(), + ] if providers and any(providers): # Filtering the providers _providers = [ @@ -49,6 +57,9 @@ def search( for provider in _providers: logger.debug(f"Looking for an LRC on {provider.__class__.__name__}") lrc = provider.get_lrc(search_term) + if enhanced and not lrc: + # Since enhanced is only supported by Musixmatch, break if no LRC is found + break if is_lrc_valid(lrc, allow_plain_format): logger.info( f'synced-lyrics found for "{search_term}" on {provider.__class__.__name__}' diff --git a/syncedlyrics/cli.py b/syncedlyrics/cli.py index 965761d..ceaf318 100644 --- a/syncedlyrics/cli.py +++ b/syncedlyrics/cli.py @@ -34,11 +34,21 @@ def cli_handler(): help="Return a plain text (not synced) lyrics if not LRC was found", action="store_true", ) + parser.add_argument( + "--enhanced", + help="Returns word by word synced lyrics (if available)", + action="store_true", + ) args = parser.parse_args() if args.verbose: logging.basicConfig(level=logging.DEBUG) lrc = search( - args.search_term, args.allow_plain, args.output, args.p, lang=args.lang + args.search_term, + args.allow_plain, + args.output, + args.p, + lang=args.lang, + enhanced=args.enhanced, ) if lrc: print(lrc) diff --git a/syncedlyrics/providers/musixmatch.py b/syncedlyrics/providers/musixmatch.py index 2d87542..2406a5a 100644 --- a/syncedlyrics/providers/musixmatch.py +++ b/syncedlyrics/providers/musixmatch.py @@ -5,7 +5,7 @@ import json import os from .base import LRCProvider -from ..utils import get_best_match +from ..utils import get_best_match, format_time # Inspired from https://github.com/Marekkon5/onetagger/blob/0654131188c4df2b4b171ded7cdb927a4369746e/crates/onetagger-platforms/src/musixmatch.rs # Huge part converted from Rust to Py by ChatGPT :) @@ -16,9 +16,10 @@ class Musixmatch(LRCProvider): ROOT_URL = "https://apic-desktop.musixmatch.com/ws/1.1/" - def __init__(self, lang: Optional[str] = None) -> None: + def __init__(self, lang: Optional[str] = None, enhanced: bool = False) -> None: super().__init__() self.lang = lang + self.enhanced = enhanced self.token = None def _get(self, action: str, query: List[tuple]): @@ -90,6 +91,20 @@ def get_lrc_by_id(self, track_id: str) -> Optional[str]: lrc = lrc.replace(org, org + "\n" + f"({tr})") return lrc + def get_lrc_word_by_word(self, track_id: str) -> Optional[str]: + r = self._get("track.richsync.get", [("track_id", track_id)]) + if r.ok and r.json()["message"]["header"]["status_code"] == 200: + lrc_raw = r.json()["message"]["body"]["richsync"]["richsync_body"] + lrc_raw = json.loads(lrc_raw) + lrc = "" + for i in lrc_raw: + lrc += f"[{format_time(i['ts'])}] " + for l in i["l"]: + t = format_time(float(i["ts"]) + float(l["o"])) + lrc += f"<{t}> {l['c']} " + lrc += "\n" + return lrc + def get_lrc(self, search_term: str) -> Optional[str]: r = self._get( "track.search", @@ -107,4 +122,7 @@ def get_lrc(self, search_term: str) -> Optional[str]: track = get_best_match(tracks, search_term, cmp_key) if not track: return None - return self.get_lrc_by_id(track["track"]["track_id"]) + track_id = track["track"]["track_id"] + if self.enhanced: + return self.get_lrc_word_by_word(track_id) + return self.get_lrc_by_id(track_id) diff --git a/syncedlyrics/utils.py b/syncedlyrics/utils.py index 3f7b9ce..ab7f619 100644 --- a/syncedlyrics/utils.py +++ b/syncedlyrics/utils.py @@ -3,6 +3,7 @@ from bs4 import BeautifulSoup, FeatureNotFound import rapidfuzz from typing import Union, Callable, Optional +import datetime import re R_FEAT = re.compile(r"\((feat.+)\)", re.IGNORECASE) @@ -37,6 +38,13 @@ def generate_bs4_soup(session, url: str, **kwargs): return soup +def format_time(time_in_seconds: float): + """Returns a [mm:ss.xx] formatted string from the given time in seconds.""" + time = datetime.timedelta(seconds=time_in_seconds) + minutes, seconds = divmod(time.seconds, 60) + return f"{minutes:02}:{seconds:02}.{time.microseconds//10000:02}" + + def str_score(a: str, b: str) -> float: """Returns the similarity score of the two strings""" # if user does not specify any "feat" in the search term, diff --git a/tests.py b/tests.py index 994defe..634c91b 100644 --- a/tests.py +++ b/tests.py @@ -33,6 +33,10 @@ def test_musixmatch_translation(): _test_provider("Musixmatch", lang="es") +def test_musixmatch_enhanced(): + _test_provider("Musixmatch", enhanced=True) + + def test_lrclib(): _test_provider("Lrclib")