From a0f988d0d8829a4eb78e445d4f76e750fc87e531 Mon Sep 17 00:00:00 2001 From: Uyanide Date: Tue, 31 Mar 2026 00:43:37 +0200 Subject: [PATCH] feat: add qqmusic fetcher --- README.md | 33 +++++-- lrcfetch/config.py | 3 + lrcfetch/core.py | 21 ++++- lrcfetch/fetchers/qqmusic.py | 173 +++++++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 lrcfetch/fetchers/qqmusic.py diff --git a/README.md b/README.md index 9f68cdd..c269524 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,12 @@ A CLI tool for fetching LRC lyrics on Linux. Automatically detects the currently Lyrics are fetched using a fallback pipeline (first synced result wins): 1. **Local** — sidecar `.lrc` files or embedded audio metadata (FLAC, MP3) -2. **Spotify** — synced lyrics via Spotify's API (requires `SPOTIFY_SP_DC`) -3. **LRCLIB** — exact match from [lrclib.net](https://lrclib.net) (requires full metadata) -4. **LRCLIB Search** — fuzzy search from lrclib.net (requires at least a title) -5. **Netease** — Netease Cloud Music public API +2. **Cache Search** — fuzzy cross-album lookup in local cache +3. **Spotify** — synced lyrics via Spotify's API (requires `SPOTIFY_SP_DC`) +4. **LRCLIB** — exact match from [lrclib.net](https://lrclib.net) (requires full metadata) +5. **LRCLIB Search** — fuzzy search from lrclib.net (requires at least a title) +6. **Netease** — Netease Cloud Music public API +7. **QQ Music** — QQ Music via self-hosted API proxy (requires `QQ_MUSIC_API_URL` that provides the same interface as [tooplick/qq-music-api](https://github.com/tooplick/qq-music-api)) ## Usage @@ -30,14 +32,15 @@ lrcfetch export lrcfetch fetch --method spotify # Cache management -lrcfetch cache --stats -lrcfetch cache --query -lrcfetch cache --clear +lrcfetch cache stats # show cache statistics +lrcfetch cache query # query cache for current track +lrcfetch cache clear # clears cache of current track +lrcfetch cache clear --all # clears entire cache ``` ## Configuration -Set `SPOTIFY_SP_DC` via environment variable or `.env` file: +Set credentials via environment variable or `.env` file: - `~/.config/lrcfetch/.env` — user-level - `.env` in working directory — project-local @@ -45,4 +48,18 @@ Set `SPOTIFY_SP_DC` via environment variable or `.env` file: ```env SPOTIFY_SP_DC=your_cookie_value +QQ_MUSIC_API_URL=https://api.example.com ``` + +Shell completion (zsh/fish/bash): + +```bash +lrcfetch --install-completion +``` + +## Credits + +- [lrclib.net](https://lrclib.net) +- [spotify-lyrics-api](https://github.com/akashrchandran/spotify-lyrics-api) +- [NeteaseCloudMusicAPI](https://www.npmjs.com/package/NeteaseCloudMusicApi?activeTab=readme) +- [qq-music-api](https://github.com/tooplick/qq-music-api) diff --git a/lrcfetch/config.py b/lrcfetch/config.py index 69fbec0..53610f5 100644 --- a/lrcfetch/config.py +++ b/lrcfetch/config.py @@ -55,6 +55,9 @@ NETEASE_LYRIC_URL = "https://interface3.music.163.com/api/song/lyric" LRCLIB_API_URL = "https://lrclib.net/api/get" LRCLIB_SEARCH_URL = "https://lrclib.net/api/search" +# QQ Music API (self-hosted proxy) +QQ_MUSIC_API_URL = os.environ.get("QQ_MUSIC_API_URL", "").rstrip("/") + # User-Agents UA_BROWSER = "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0" UA_LRCFETCH = "LRCFetch (https://github.com/Uyanide/lrcfetch)" diff --git a/lrcfetch/core.py b/lrcfetch/core.py index c380cbf..2bb59ae 100644 --- a/lrcfetch/core.py +++ b/lrcfetch/core.py @@ -17,6 +17,7 @@ from loguru import logger from typing import Literal from .fetchers.netease import NeteaseFetcher +from .fetchers.qqmusic import QQMusicFetcher from .fetchers.lrclib_search import LrclibSearchFetcher from .fetchers.lrclib import LrclibFetcher from .fetchers.spotify import SpotifyFetcher @@ -28,9 +29,23 @@ from .lrc import LRC_LINE_RE, normalize_tags from .config import TTL_SYNCED, TTL_UNSYNCED, TTL_NOT_FOUND, TTL_NETWORK_ERROR from .models import TrackMeta, LyricResult, CacheStatus -METHODS = ("local", "cache-search", "spotify", "lrclib", "lrclib-search", "netease") +METHODS = ( + "local", + "cache-search", + "spotify", + "lrclib", + "lrclib-search", + "netease", + "qqmusic", +) FetcherMethodType = Literal[ - "local", "cache-search", "spotify", "lrclib", "lrclib-search", "netease" + "local", + "cache-search", + "spotify", + "lrclib", + "lrclib-search", + "netease", + "qqmusic", ] @@ -78,6 +93,7 @@ class LrcManager: "lrclib": LrclibFetcher(), "lrclib-search": LrclibSearchFetcher(), "netease": NeteaseFetcher(), + "qqmusic": QQMusicFetcher(), } assert set(self.fetchers) == set(METHODS), ( f"METHODS and fetchers out of sync: {set(METHODS) ^ set(self.fetchers)}" @@ -105,6 +121,7 @@ class LrcManager: if track.title: sequence.append(self.fetchers["lrclib-search"]) sequence.append(self.fetchers["netease"]) + sequence.append(self.fetchers["qqmusic"]) logger.debug(f"Fallback sequence: {[f.source_name for f in sequence]}") return sequence diff --git a/lrcfetch/fetchers/qqmusic.py b/lrcfetch/fetchers/qqmusic.py new file mode 100644 index 0000000..9f90ca9 --- /dev/null +++ b/lrcfetch/fetchers/qqmusic.py @@ -0,0 +1,173 @@ +""" +Description: QQ Music fetcher via self-hosted API proxy +""" + +""" +Requires a running qq-music-api instance (Cloudflare Worker). +The base URL is read from the QQ_MUSIC_API_URL environment variable. + +Search → pick best match by duration → fetch LRC lyrics. +""" + +from typing import Optional +import httpx +from loguru import logger + +from .base import BaseFetcher +from ..models import TrackMeta, LyricResult, CacheStatus +from ..lrc import is_synced +from ..config import ( + HTTP_TIMEOUT, + TTL_NOT_FOUND, + TTL_NETWORK_ERROR, + DURATION_TOLERANCE_MS, + QQ_MUSIC_API_URL, +) + + +class QQMusicFetcher(BaseFetcher): + @property + def source_name(self) -> str: + return "qqmusic" + + def _search(self, track: TrackMeta, limit: int = 10) -> Optional[str]: + """Search QQ Music and return the best-matching song MID.""" + query = f"{track.artist or ''} {track.title or ''}".strip() + if not query: + return None + + logger.debug(f"QQMusic: searching for '{query}' (limit={limit})") + + try: + with httpx.Client(timeout=HTTP_TIMEOUT) as client: + resp = client.get( + f"{QQ_MUSIC_API_URL}/api/search", + params={"keyword": query, "type": "song", "num": limit}, + ) + resp.raise_for_status() + data = resp.json() + + if data.get("code") != 0: + logger.error(f"QQMusic: search API error: {data}") + return None + + songs = data.get("data", {}).get("list", []) + if not songs: + logger.debug("QQMusic: search returned 0 results") + return None + + logger.debug(f"QQMusic: search returned {len(songs)} candidates") + + # Duration-based best-match selection + if track.length is not None: + track_ms = track.length + best_mid: Optional[str] = None + best_diff = float("inf") + + for song in songs: + if not isinstance(song, dict): + continue + mid = song.get("mid") + name = song.get("name", "?") + # interval is in seconds + interval = song.get("interval") + if not isinstance(interval, int): + logger.debug( + f" candidate {mid} '{name}': no duration, skipped" + ) + continue + duration_ms = interval * 1000 + diff = abs(duration_ms - track_ms) + logger.debug( + f" candidate {mid} '{name}': " + f"duration={duration_ms}ms, diff={diff}ms" + ) + if diff < best_diff: + best_diff = diff + best_mid = mid + + if best_mid is not None and best_diff <= DURATION_TOLERANCE_MS: + logger.debug( + f"QQMusic: selected mid={best_mid} (diff={best_diff}ms)" + ) + return best_mid + + logger.debug( + f"QQMusic: no candidate within {DURATION_TOLERANCE_MS}ms " + f"(best diff={best_diff}ms)" + ) + return None + + # No duration info — take the first result + first = songs[0] + if not isinstance(first, dict) or "mid" not in first: + logger.error("QQMusic: first search result has no 'mid'") + return None + logger.debug( + f"QQMusic: no duration available, using first result " + f"mid={first['mid']} '{first.get('name', '?')}'" + ) + return first["mid"] + + except Exception as e: + logger.error(f"QQMusic: search failed: {e}") + return None + + def _get_lyric(self, mid: str) -> Optional[LyricResult]: + """Fetch lyrics for a given QQ Music song MID.""" + logger.debug(f"QQMusic: fetching lyrics for mid={mid}") + + try: + with httpx.Client(timeout=HTTP_TIMEOUT) as client: + resp = client.get( + f"{QQ_MUSIC_API_URL}/api/lyric", + params={"mid": mid}, + ) + resp.raise_for_status() + data = resp.json() + + if data.get("code") != 0: + logger.error(f"QQMusic: lyric API error: {data}") + return LyricResult( + status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR + ) + + lrc = data.get("data", {}).get("lyric", "") + if not isinstance(lrc, str) or not lrc.strip(): + logger.debug(f"QQMusic: empty lyrics for mid={mid}") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + synced = is_synced(lrc) + status = ( + CacheStatus.SUCCESS_SYNCED if synced else CacheStatus.SUCCESS_UNSYNCED + ) + logger.info( + f"QQMusic: got {status.value} lyrics for mid={mid} " + f"({len(lrc.splitlines())} lines)" + ) + return LyricResult( + status=status, lyrics=lrc.strip(), source=self.source_name + ) + + except Exception as e: + logger.error(f"QQMusic: lyric fetch failed for mid={mid}: {e}") + return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR) + + def fetch(self, track: TrackMeta) -> Optional[LyricResult]: + """Search for the track and fetch its lyrics.""" + if not QQ_MUSIC_API_URL: + logger.debug("QQMusic: skipped — QQ_MUSIC_API_URL not configured") + return None + + query = f"{track.artist or ''} {track.title or ''}".strip() + if not query: + logger.debug("QQMusic: skipped — insufficient metadata") + return None + + logger.info(f"QQMusic: fetching lyrics for {track.display_name()}") + mid = self._search(track) + if not mid: + logger.debug(f"QQMusic: no match found for {track.display_name()}") + return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) + + return self._get_lyric(mid)