diff --git a/addons/lyrics.py b/addons/lyrics.py index 40b8e1d..e05168a 100644 --- a/addons/lyrics.py +++ b/addons/lyrics.py @@ -21,9 +21,17 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -import aiohttp, random, bs4, re +import aiohttp +import random +import re +import hmac +import hashlib +import base64 +import json +import urllib.parse import function as func +from datetime import datetime from abc import ABC, abstractmethod from urllib.parse import quote from math import floor @@ -71,9 +79,6 @@ Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_6; en-US) AppleWebKit/530.9 (KHTM Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_6; en-US) AppleWebKit/530.6 (KHTML, like Gecko) Chrome/ Safari/530.6 Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_5_6; en-US) AppleWebKit/530.5 (KHTML, like Gecko) Chrome/ Safari/530.5''' -LYRIST_ENDPOINT = "https://lyrist.vercel.app/api/" -LRCLIB_ENDPOINT = "https://lrclib.net/api/" - class LyricsPlatform(ABC): @abstractmethod async def get_lyrics(self, title: str, artist: str) -> Optional[dict[str, str]]: @@ -207,9 +212,12 @@ class Genius(LyricsPlatform): return {"default": song.lyrics} class Lyrist(LyricsPlatform): + def __init__(self): + self.base_url: str = "https://lyrist.vercel.app/api/" + async def get_lyrics(self, title: str, artist: str) -> Optional[dict[str, str]]: try: - request_url = LYRIST_ENDPOINT + title + "/" + artist + request_url = self.base_url + title + "/" + artist async with aiohttp.ClientSession() as session: resp = await session.get(url=request_url, headers={'User-Agent': random.choice(userAgents)}) if resp.status != 200: @@ -221,6 +229,9 @@ class Lyrist(LyricsPlatform): return None class Lrclib(LyricsPlatform): + def __init__(self): + self.base_url: str = "https://lrclib.net/api/" + async def get(self, url, params: dict = None) -> list[dict]: try: async with aiohttp.ClientSession() as session: @@ -231,15 +242,135 @@ class Lrclib(LyricsPlatform): except: return [] - async def get_lyrics(self, title, artist): + async def get_lyrics(self, title: str, artist: str) -> Optional[dict[str, str]]: params = {"q": title} - result = await self.get(LRCLIB_ENDPOINT + "search", params) + result = await self.get(self.base_url + "search", params) if result: return {"default": result[0].get("plainLyrics", "")} +""" +Strvm/musicxmatch-api: a reverse engineered API wrapper for MusicXMatch +Copyright (c) 2025 Strvm + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +class MusixMatch(LyricsPlatform): + def __init__(self): + self.base_url = "https://www.musixmatch.com/ws/1.1/" + self.headers = {'User-Agent': random.choice(userAgents)} + self.secret: Optional[str] = None + + async def search_tracks(self, track_query: str, page: int = 1) -> dict: + url = f"track.search?app_id=web-desktop-app-v1.0&format=json&q={urllib.parse.quote(track_query)}&f_has_lyrics=true&page_size=5&page={page}" + return await self.make_request(url) + + async def get_track_lyrics(self, track_id: Optional[str] = None, track_isrc: Optional[str] = None) -> dict: + if not (track_id or track_isrc): + raise ValueError("Either track_id or track_isrc must be provided.") + param = f"track_id={track_id}" if track_id else f"track_isrc={track_isrc}" + url = f"track.lyrics.get?app_id=web-desktop-app-v1.0&format=json&{param}" + return await self.make_request(url) + + async def get_latest_app(self): + url = "https://www.musixmatch.com/search" + + async with aiohttp.ClientSession() as session: + async with session.get(url, headers={**self.headers, "Cookie": "mxm_bab=AB"}) as response: + html_content = await response.text() + pattern = r'src="([^"]*/_next/static/chunks/pages/_app-[^"]+\.js)"' + matches = re.findall(pattern, html_content) + + if not matches: + raise Exception("_app URL not found in the HTML content.") + + return matches[-1] + + async def get_secret(self) -> str: + async with aiohttp.ClientSession() as session: + async with session.get(await self.get_latest_app(), headers=self.headers, timeout=5) as response: + javascript_code = await response.text() + + pattern = r'from\(\s*"(.*?)"\s*\.split' + match = re.search(pattern, javascript_code) + + if match: + encoded_string = match.group(1) + reversed_string = encoded_string[::-1] + + decoded_bytes = base64.b64decode(reversed_string) + return decoded_bytes.decode("utf-8") + else: + raise Exception("Encoded string not found in the JavaScript code.") + + async def generate_signature(self, url: str) -> str: + current_date = datetime.now() + date_str = f"{current_date.year}{str(current_date.month).zfill(2)}{str(current_date.day).zfill(2)}" + message = (url + date_str).encode() + + if not self.secret: + self.secret = await self.get_secret() + + key = self.secret.encode() + hash_output = hmac.new(key, message, hashlib.sha256).digest() + signature = ( + "&signature=" + + urllib.parse.quote(base64.b64encode(hash_output).decode()) + + "&signature_protocol=sha256" + ) + return signature + + async def make_request(self, url: str) -> dict: + url = url.replace("%20", "+").replace(" ", "+") + url = self.base_url + url + signed_url = url + await self.generate_signature(url) + + async with aiohttp.ClientSession() as session: + async with session.get(signed_url, headers=self.headers, timeout=5) as response: + if response.status != 200: + raise Exception(f"HTTP Error: {response.status} for URL: {signed_url}") + + try: + text_data = await response.text() + return json.loads(text_data) + except json.JSONDecodeError: + raise Exception(f"Failed to parse JSON. Response: {text_data}") + + async def get_lyrics(self, title: str, artist: str) -> Optional[dict[str, str]]: + results = await self.search_tracks(track_query=f"{artist} {title}" if artist else title) + + track_list = results.get("message", {}).get("body", {}).get("track_list") + if not track_list: + return None + + track_id = track_list[0]["track"]["track_id"] + lyric = await self.get_track_lyrics(track_id=track_id) + + lyrics_body = lyric.get("message", {}).get("body", {}).get("lyrics", {}).get("lyrics_body", "") + if not lyrics_body: + return None + + return {"default": lyrics_body} + LYRICS_PLATFORMS: dict[str, Type[LyricsPlatform]] = { "a_zlyrics": A_ZLyrics, "genius": Genius, "lyrist": Lyrist, - "lrclib": Lrclib + "lrclib": Lrclib, + "musixmatch": MusixMatch } \ No newline at end of file