feat: add qqmusic fetcher
This commit is contained in:
@@ -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):
|
Lyrics are fetched using a fallback pipeline (first synced result wins):
|
||||||
|
|
||||||
1. **Local** — sidecar `.lrc` files or embedded audio metadata (FLAC, MP3)
|
1. **Local** — sidecar `.lrc` files or embedded audio metadata (FLAC, MP3)
|
||||||
2. **Spotify** — synced lyrics via Spotify's API (requires `SPOTIFY_SP_DC`)
|
2. **Cache Search** — fuzzy cross-album lookup in local cache
|
||||||
3. **LRCLIB** — exact match from [lrclib.net](https://lrclib.net) (requires full metadata)
|
3. **Spotify** — synced lyrics via Spotify's API (requires `SPOTIFY_SP_DC`)
|
||||||
4. **LRCLIB Search** — fuzzy search from lrclib.net (requires at least a title)
|
4. **LRCLIB** — exact match from [lrclib.net](https://lrclib.net) (requires full metadata)
|
||||||
5. **Netease** — Netease Cloud Music public API
|
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
|
## Usage
|
||||||
|
|
||||||
@@ -30,14 +32,15 @@ lrcfetch export
|
|||||||
lrcfetch fetch --method spotify
|
lrcfetch fetch --method spotify
|
||||||
|
|
||||||
# Cache management
|
# Cache management
|
||||||
lrcfetch cache --stats
|
lrcfetch cache stats # show cache statistics
|
||||||
lrcfetch cache --query
|
lrcfetch cache query # query cache for current track
|
||||||
lrcfetch cache --clear
|
lrcfetch cache clear # clears cache of current track
|
||||||
|
lrcfetch cache clear --all # clears entire cache
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Set `SPOTIFY_SP_DC` via environment variable or `.env` file:
|
Set credentials via environment variable or `.env` file:
|
||||||
|
|
||||||
- `~/.config/lrcfetch/.env` — user-level
|
- `~/.config/lrcfetch/.env` — user-level
|
||||||
- `.env` in working directory — project-local
|
- `.env` in working directory — project-local
|
||||||
@@ -45,4 +48,18 @@ Set `SPOTIFY_SP_DC` via environment variable or `.env` file:
|
|||||||
|
|
||||||
```env
|
```env
|
||||||
SPOTIFY_SP_DC=your_cookie_value
|
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)
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ NETEASE_LYRIC_URL = "https://interface3.music.163.com/api/song/lyric"
|
|||||||
LRCLIB_API_URL = "https://lrclib.net/api/get"
|
LRCLIB_API_URL = "https://lrclib.net/api/get"
|
||||||
LRCLIB_SEARCH_URL = "https://lrclib.net/api/search"
|
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
|
# User-Agents
|
||||||
UA_BROWSER = "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0"
|
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)"
|
UA_LRCFETCH = "LRCFetch (https://github.com/Uyanide/lrcfetch)"
|
||||||
|
|||||||
+19
-2
@@ -17,6 +17,7 @@ from loguru import logger
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from .fetchers.netease import NeteaseFetcher
|
from .fetchers.netease import NeteaseFetcher
|
||||||
|
from .fetchers.qqmusic import QQMusicFetcher
|
||||||
from .fetchers.lrclib_search import LrclibSearchFetcher
|
from .fetchers.lrclib_search import LrclibSearchFetcher
|
||||||
from .fetchers.lrclib import LrclibFetcher
|
from .fetchers.lrclib import LrclibFetcher
|
||||||
from .fetchers.spotify import SpotifyFetcher
|
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 .config import TTL_SYNCED, TTL_UNSYNCED, TTL_NOT_FOUND, TTL_NETWORK_ERROR
|
||||||
from .models import TrackMeta, LyricResult, CacheStatus
|
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[
|
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": LrclibFetcher(),
|
||||||
"lrclib-search": LrclibSearchFetcher(),
|
"lrclib-search": LrclibSearchFetcher(),
|
||||||
"netease": NeteaseFetcher(),
|
"netease": NeteaseFetcher(),
|
||||||
|
"qqmusic": QQMusicFetcher(),
|
||||||
}
|
}
|
||||||
assert set(self.fetchers) == set(METHODS), (
|
assert set(self.fetchers) == set(METHODS), (
|
||||||
f"METHODS and fetchers out of sync: {set(METHODS) ^ set(self.fetchers)}"
|
f"METHODS and fetchers out of sync: {set(METHODS) ^ set(self.fetchers)}"
|
||||||
@@ -105,6 +121,7 @@ class LrcManager:
|
|||||||
if track.title:
|
if track.title:
|
||||||
sequence.append(self.fetchers["lrclib-search"])
|
sequence.append(self.fetchers["lrclib-search"])
|
||||||
sequence.append(self.fetchers["netease"])
|
sequence.append(self.fetchers["netease"])
|
||||||
|
sequence.append(self.fetchers["qqmusic"])
|
||||||
|
|
||||||
logger.debug(f"Fallback sequence: {[f.source_name for f in sequence]}")
|
logger.debug(f"Fallback sequence: {[f.source_name for f in sequence]}")
|
||||||
return sequence
|
return sequence
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user