Compare commits

..

2 Commits

Author SHA1 Message Date
Uyanide 1ed51fdbdb feat: enricher: +1 2026-04-05 02:25:33 +02:00
Uyanide d8c3813e39 refactor: move out some constants from config.py 2026-04-05 02:25:33 +02:00
11 changed files with 106 additions and 17 deletions
+1
View File
@@ -102,3 +102,4 @@ lrx --install-completion
- [librelyrics-spotify](https://github.com/libre-lyrics/librelyrics-spotify) - [librelyrics-spotify](https://github.com/libre-lyrics/librelyrics-spotify)
- [NeteaseCloudMusicAPI](https://www.npmjs.com/package/NeteaseCloudMusicApi?activeTab=readme) - [NeteaseCloudMusicAPI](https://www.npmjs.com/package/NeteaseCloudMusicApi?activeTab=readme)
- [qq-music-api](https://github.com/tooplick/qq-music-api) - [qq-music-api](https://github.com/tooplick/qq-music-api)
- [Rise Media Player](https://github.com/theimpactfulcompany/Rise-Media-Player)
+3 -1
View File
@@ -67,7 +67,6 @@ SPOTIFY_SECRET_URL = (
) )
SPOTIFY_SP_DC = os.environ.get("SPOTIFY_SP_DC", "") SPOTIFY_SP_DC = os.environ.get("SPOTIFY_SP_DC", "")
SPOTIFY_TOKEN_CACHE_FILE = os.path.join(CACHE_DIR, "spotify_token.json") SPOTIFY_TOKEN_CACHE_FILE = os.path.join(CACHE_DIR, "spotify_token.json")
SPOTIFY_APP_VERSION = "1.2.88.21.g8e037c8f"
# Netease api # Netease api
NETEASE_SEARCH_URL = "https://music.163.com/api/cloudsearch/pc" NETEASE_SEARCH_URL = "https://music.163.com/api/cloudsearch/pc"
@@ -84,6 +83,9 @@ QQ_MUSIC_API_URL = os.environ.get("QQ_MUSIC_API_URL", "").rstrip("/")
MUSIXMATCH_USERTOKEN = os.environ.get("MUSIXMATCH_USERTOKEN", "") MUSIXMATCH_USERTOKEN = os.environ.get("MUSIXMATCH_USERTOKEN", "")
MUSIXMATCH_SEARCH_URL = "https://apic-desktop.musixmatch.com/ws/1.1/track.search" 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_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) # Player preference (used when multiple MPRIS players are active)
PREFERRED_PLAYER = os.environ.get("PREFERRED_PLAYER", "spotify") PREFERRED_PLAYER = os.environ.get("PREFERRED_PLAYER", "spotify")
+2 -2
View File
@@ -161,7 +161,7 @@ class LrcManager:
force_method: Optional[FetcherMethodType], force_method: Optional[FetcherMethodType],
bypass_cache: bool, bypass_cache: bool,
) -> Optional[LyricResult]: ) -> Optional[LyricResult]:
track = enrich_track(track) track = await enrich_track(track)
logger.info(f"Fetching lyrics for: {track.display_name()}") logger.info(f"Fetching lyrics for: {track.display_name()}")
plan = build_plan(self.fetchers, track, force_method) plan = build_plan(self.fetchers, track, force_method)
@@ -217,7 +217,7 @@ class LrcManager:
lyrics: str, lyrics: str,
) -> None: ) -> None:
"""Manually insert lyrics into the cache for a track.""" """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()}") logger.info(f"Manually inserting lyrics for: {track.display_name()}")
lrc = LRCData(lyrics) lrc = LRCData(lyrics)
result = LyricResult( result = LyricResult(
+5 -2
View File
@@ -9,16 +9,19 @@ from loguru import logger
from .base import BaseEnricher from .base import BaseEnricher
from .audio_tag import AudioTagEnricher from .audio_tag import AudioTagEnricher
from .file_name import FileNameEnricher from .file_name import FileNameEnricher
from .musixmatch import MusixmatchSpotifyEnricher
from ..models import TrackMeta from ..models import TrackMeta
# Enrichers run in order; earlier ones have higher priority. # 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] = [ _ENRICHERS: list[BaseEnricher] = [
AudioTagEnricher(), AudioTagEnricher(),
FileNameEnricher(), 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. """Run all enrichers and return a track with missing fields filled in.
Each enricher sees the cumulative state (earlier enrichers' results Each enricher sees the cumulative state (earlier enrichers' results
@@ -32,7 +35,7 @@ def enrich_track(track: TrackMeta) -> TrackMeta:
): ):
continue continue
result = enricher.enrich(track) result = await enricher.enrich(track)
except Exception as e: except Exception as e:
logger.warning(f"Enricher {enricher.name} failed: {e}") logger.warning(f"Enricher {enricher.name} failed: {e}")
continue continue
+1 -1
View File
@@ -24,7 +24,7 @@ class AudioTagEnricher(BaseEnricher):
def provides(self) -> set[str]: def provides(self) -> set[str]:
return {"title", "artist", "album", "length"} 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: if not track.is_local or not track.url:
return None return None
+1 -1
View File
@@ -27,7 +27,7 @@ class BaseEnricher(ABC):
def provides(self) -> set[str]: ... def provides(self) -> set[str]: ...
@abstractmethod @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 a dict of {field_name: value} for fields this enricher can fill.
Return None or an empty dict if nothing can be contributed. Return None or an empty dict if nothing can be contributed.
+1 -1
View File
@@ -37,7 +37,7 @@ class FileNameEnricher(BaseEnricher):
def provides(self) -> set[str]: def provides(self) -> set[str]:
return {"artist", "title", "album"} 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: if not track.is_local or not track.url:
return None return None
+80
View File
@@ -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
+9 -6
View File
@@ -31,7 +31,6 @@ from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData from ..lrc import LRCData
from ..config import ( from ..config import (
HTTP_TIMEOUT, HTTP_TIMEOUT,
SPOTIFY_APP_VERSION,
TTL_NOT_FOUND, TTL_NOT_FOUND,
TTL_NETWORK_ERROR, TTL_NETWORK_ERROR,
SPOTIFY_TOKEN_URL, SPOTIFY_TOKEN_URL,
@@ -43,6 +42,13 @@ from ..config import (
UA_BROWSER, UA_BROWSER,
) )
_SPOTIFY_BASE_HEADERS = {
"Referer": "https://open.spotify.com/",
"Origin": "https://open.spotify.com",
"App-Platform": "WebPlayer",
"Spotify-App-Version": "1.2.88.21.g8e037c8f",
}
class SpotifyFetcher(BaseFetcher): class SpotifyFetcher(BaseFetcher):
def __init__(self) -> None: def __init__(self) -> None:
@@ -198,8 +204,8 @@ class SpotifyFetcher(BaseFetcher):
headers = { headers = {
"User-Agent": UA_BROWSER, "User-Agent": UA_BROWSER,
"Accept": "*/*", "Accept": "*/*",
"Referer": "https://open.spotify.com/",
"Cookie": f"sp_dc={SPOTIFY_SP_DC}", "Cookie": f"sp_dc={SPOTIFY_SP_DC}",
**_SPOTIFY_BASE_HEADERS,
} }
async with httpx.AsyncClient(headers=headers) as client: async with httpx.AsyncClient(headers=headers) as client:
@@ -281,10 +287,7 @@ class SpotifyFetcher(BaseFetcher):
"User-Agent": UA_BROWSER, "User-Agent": UA_BROWSER,
"Accept": "application/json", "Accept": "application/json",
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"Referer": "https://open.spotify.com/", **_SPOTIFY_BASE_HEADERS,
"App-Platform": "WebPlayer",
"Spotify-App-Version": SPOTIFY_APP_VERSION,
"Origin": "https://open.spotify.com",
} }
try: try:
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "lrx-cli" name = "lrx-cli"
version = "0.4.5" version = "0.4.6"
description = "Fetch line-synced lyrics for your music player." description = "Fetch line-synced lyrics for your music player."
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
@@ -22,7 +22,7 @@ dependencies = [
lrx = "lrx_cli.cli:run" lrx = "lrx_cli.cli:run"
[tool.ruff.lint] [tool.ruff.lint]
ignore = ["E402"] ignore = ["E402"] # Since there are headers
[dependency-groups] [dependency-groups]
dev = ["pytest>=9.0.2", "ruff>=0.15.8"] dev = ["pytest>=9.0.2", "ruff>=0.15.8"]
Generated
+1 -1
View File
@@ -153,7 +153,7 @@ wheels = [
[[package]] [[package]]
name = "lrx-cli" name = "lrx-cli"
version = "0.4.4" version = "0.4.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "cyclopts" }, { name = "cyclopts" },