diff --git a/README.md b/README.md index bb77efd..f0fd425 100644 --- a/README.md +++ b/README.md @@ -102,3 +102,4 @@ lrx --install-completion - [librelyrics-spotify](https://github.com/libre-lyrics/librelyrics-spotify) - [NeteaseCloudMusicAPI](https://www.npmjs.com/package/NeteaseCloudMusicApi?activeTab=readme) - [qq-music-api](https://github.com/tooplick/qq-music-api) +- [Rise Media Player](https://github.com/theimpactfulcompany/Rise-Media-Player) diff --git a/lrx_cli/config.py b/lrx_cli/config.py index 068623e..112b0de 100644 --- a/lrx_cli/config.py +++ b/lrx_cli/config.py @@ -83,6 +83,9 @@ QQ_MUSIC_API_URL = os.environ.get("QQ_MUSIC_API_URL", "").rstrip("/") MUSIXMATCH_USERTOKEN = os.environ.get("MUSIXMATCH_USERTOKEN", "") MUSIXMATCH_SEARCH_URL = "https://apic-desktop.musixmatch.com/ws/1.1/track.search" MUSIXMATCH_MACRO_URL = "https://apic-desktop.musixmatch.com/ws/1.1/macro.subtitles.get" +MUSIXMATCH_TRACK_MATCH_URL = ( + "https://apic-desktop.musixmatch.com/ws/1.1/matcher.track.get" +) # Player preference (used when multiple MPRIS players are active) PREFERRED_PLAYER = os.environ.get("PREFERRED_PLAYER", "spotify") diff --git a/lrx_cli/core.py b/lrx_cli/core.py index 36aacbf..f191542 100644 --- a/lrx_cli/core.py +++ b/lrx_cli/core.py @@ -161,7 +161,7 @@ class LrcManager: force_method: Optional[FetcherMethodType], bypass_cache: bool, ) -> Optional[LyricResult]: - track = enrich_track(track) + track = await enrich_track(track) logger.info(f"Fetching lyrics for: {track.display_name()}") plan = build_plan(self.fetchers, track, force_method) @@ -217,7 +217,7 @@ class LrcManager: lyrics: str, ) -> None: """Manually insert lyrics into the cache for a track.""" - track = enrich_track(track) + track = asyncio.run(enrich_track(track)) logger.info(f"Manually inserting lyrics for: {track.display_name()}") lrc = LRCData(lyrics) result = LyricResult( diff --git a/lrx_cli/enrichers/__init__.py b/lrx_cli/enrichers/__init__.py index 562533f..c824606 100644 --- a/lrx_cli/enrichers/__init__.py +++ b/lrx_cli/enrichers/__init__.py @@ -9,16 +9,19 @@ from loguru import logger from .base import BaseEnricher from .audio_tag import AudioTagEnricher from .file_name import FileNameEnricher +from .musixmatch import MusixmatchSpotifyEnricher from ..models import TrackMeta # Enrichers run in order; earlier ones have higher priority. +# There are only a few of them, so we can just call them sequentially without worrying about async concurrency or batching. _ENRICHERS: list[BaseEnricher] = [ AudioTagEnricher(), FileNameEnricher(), + MusixmatchSpotifyEnricher(), ] -def enrich_track(track: TrackMeta) -> TrackMeta: +async def enrich_track(track: TrackMeta) -> TrackMeta: """Run all enrichers and return a track with missing fields filled in. Each enricher sees the cumulative state (earlier enrichers' results @@ -32,7 +35,7 @@ def enrich_track(track: TrackMeta) -> TrackMeta: ): continue - result = enricher.enrich(track) + result = await enricher.enrich(track) except Exception as e: logger.warning(f"Enricher {enricher.name} failed: {e}") continue diff --git a/lrx_cli/enrichers/audio_tag.py b/lrx_cli/enrichers/audio_tag.py index 47be16f..90aebe1 100644 --- a/lrx_cli/enrichers/audio_tag.py +++ b/lrx_cli/enrichers/audio_tag.py @@ -24,7 +24,7 @@ class AudioTagEnricher(BaseEnricher): def provides(self) -> set[str]: return {"title", "artist", "album", "length"} - def enrich(self, track: TrackMeta) -> Optional[dict]: + async def enrich(self, track: TrackMeta) -> Optional[dict]: if not track.is_local or not track.url: return None diff --git a/lrx_cli/enrichers/base.py b/lrx_cli/enrichers/base.py index a4813e5..ebdc100 100644 --- a/lrx_cli/enrichers/base.py +++ b/lrx_cli/enrichers/base.py @@ -27,7 +27,7 @@ class BaseEnricher(ABC): def provides(self) -> set[str]: ... @abstractmethod - def enrich(self, track: TrackMeta) -> Optional[dict]: + async def enrich(self, track: TrackMeta) -> Optional[dict]: """Return a dict of {field_name: value} for fields this enricher can fill. Return None or an empty dict if nothing can be contributed. diff --git a/lrx_cli/enrichers/file_name.py b/lrx_cli/enrichers/file_name.py index 11f8442..c8aacf1 100644 --- a/lrx_cli/enrichers/file_name.py +++ b/lrx_cli/enrichers/file_name.py @@ -37,7 +37,7 @@ class FileNameEnricher(BaseEnricher): def provides(self) -> set[str]: return {"artist", "title", "album"} - def enrich(self, track: TrackMeta) -> Optional[dict]: + async def enrich(self, track: TrackMeta) -> Optional[dict]: if not track.is_local or not track.url: return None diff --git a/lrx_cli/enrichers/musixmatch.py b/lrx_cli/enrichers/musixmatch.py new file mode 100644 index 0000000..8b6d5a1 --- /dev/null +++ b/lrx_cli/enrichers/musixmatch.py @@ -0,0 +1,80 @@ +""" +Author: Uyanide pywang0608@foxmail.com +Date: 2026-04-05 02:13:49 +Description: Musixmatch metadata enricher (matcher.track.get by Spotify track ID) +""" + +from typing import Optional +from urllib.parse import urlencode + +import httpx +from loguru import logger + +from .base import BaseEnricher +from ..models import TrackMeta +from ..config import ( + HTTP_TIMEOUT, + MUSIXMATCH_TRACK_MATCH_URL, + MUSIXMATCH_USERTOKEN, +) + +_MXM_HEADERS = {"Cookie": "x-mxm-token-guid="} +_MXM_TRACK_MATCH_BASE_PARAMS = { + "format": "json", + "app_id": "web-desktop-app-v1.0", + "usertoken": MUSIXMATCH_USERTOKEN, +} + + +class MusixmatchSpotifyEnricher(BaseEnricher): + """Fill title, artist, album, and length from Musixmatch using Spotify track ID.""" + + @property + def name(self) -> str: + return "musixmatch" + + @property + def provides(self) -> set[str]: + return {"title", "artist", "album", "length"} + + async def enrich(self, track: TrackMeta) -> Optional[dict]: + if not track.trackid or not MUSIXMATCH_USERTOKEN: + return None + + params = { + **_MXM_TRACK_MATCH_BASE_PARAMS, + "track_spotify_id": track.trackid, + } + url = f"{MUSIXMATCH_TRACK_MATCH_URL}?{urlencode(params)}" + logger.debug(f"Musixmatch enricher: looking up trackid={track.trackid}") + + try: + async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client: + resp = await client.get(url, headers=_MXM_HEADERS) + resp.raise_for_status() + data = resp.json() + except Exception as e: + logger.warning(f"Musixmatch enricher: request failed: {e}") + return None + + body = data.get("message", {}).get("body") + t = body.get("track") if isinstance(body, dict) else None + if not isinstance(t, dict): + logger.debug( + f"Musixmatch enricher: no track data for trackid={track.trackid}" + ) + return None + + updates: dict = {} + if isinstance(t.get("track_name"), str) and t["track_name"]: + updates["title"] = t["track_name"] + if isinstance(t.get("artist_name"), str) and t["artist_name"]: + updates["artist"] = t["artist_name"] + if isinstance(t.get("album_name"), str) and t["album_name"]: + updates["album"] = t["album_name"] + if isinstance(t.get("track_length"), int) and t["track_length"] > 0: + updates["length"] = t["track_length"] * 1000 + + if updates: + logger.debug(f"Musixmatch enricher: filled {list(updates.keys())}") + return updates or None diff --git a/pyproject.toml b/pyproject.toml index 32ee35d..c3a1e01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lrx-cli" -version = "0.4.5" +version = "0.4.6" description = "Fetch line-synced lyrics for your music player." readme = "README.md" requires-python = ">=3.13" @@ -22,7 +22,7 @@ dependencies = [ lrx = "lrx_cli.cli:run" [tool.ruff.lint] -ignore = ["E402"] +ignore = ["E402"] # Since there are headers [dependency-groups] dev = ["pytest>=9.0.2", "ruff>=0.15.8"] diff --git a/uv.lock b/uv.lock index 522b9b9..d554f1e 100644 --- a/uv.lock +++ b/uv.lock @@ -153,7 +153,7 @@ wheels = [ [[package]] name = "lrx-cli" -version = "0.4.4" +version = "0.4.5" source = { editable = "." } dependencies = [ { name = "cyclopts" },