feat: config file

refactor: as the config module changed
test: add test for config
test: add test for local fetcher and local enrichers
test: add test for manual insertion
fix: some random bugs left by the last commit
This commit is contained in:
2026-04-09 15:16:21 +02:00
parent e6b8583868
commit d2a3e64b89
34 changed files with 749 additions and 413 deletions
+13 -11
View File
@@ -23,6 +23,7 @@ from ..authenticators import (
QQMusicAuthenticator,
)
from ..cache import CacheEngine
from ..config import AppConfig
from ..models import TrackMeta
FetcherMethodType = Literal[
@@ -52,26 +53,27 @@ _FETCHER_GROUPS: list[list[FetcherMethodType]] = [
def create_fetchers(
cache: CacheEngine,
authenticators: dict[str, BaseAuthenticator],
config: AppConfig,
) -> dict[FetcherMethodType, BaseFetcher]:
"""Instantiate all fetchers. Returns a dict keyed by source name."""
spotify_auth = authenticators["spotify"]
mxm_auth = authenticators["musixmatch"]
qqmusic_auth = authenticators.get("qqmusic")
qqmusic_auth = authenticators["qqmusic"]
assert isinstance(spotify_auth, SpotifyAuthenticator)
assert isinstance(mxm_auth, MusixmatchAuthenticator)
assert isinstance(qqmusic_auth, QQMusicAuthenticator)
fetchers: dict[FetcherMethodType, BaseFetcher] = {
"local": LocalFetcher(),
g = config.general
return {
"local": LocalFetcher(g),
"cache-search": CacheSearchFetcher(cache),
"spotify": SpotifyFetcher(spotify_auth),
"lrclib": LrclibFetcher(),
"musixmatch-spotify": MusixmatchSpotifyFetcher(mxm_auth),
"lrclib-search": LrclibSearchFetcher(),
"netease": NeteaseFetcher(),
"qqmusic": QQMusicFetcher(qqmusic_auth),
"musixmatch": MusixmatchFetcher(mxm_auth),
"spotify": SpotifyFetcher(g, spotify_auth),
"lrclib": LrclibFetcher(g),
"musixmatch-spotify": MusixmatchSpotifyFetcher(g, mxm_auth),
"lrclib-search": LrclibSearchFetcher(g),
"netease": NeteaseFetcher(g),
"qqmusic": QQMusicFetcher(g, qqmusic_auth),
"musixmatch": MusixmatchFetcher(g, mxm_auth),
}
return fetchers
def build_plan(
+8
View File
@@ -8,6 +8,8 @@ from abc import ABC, abstractmethod
from typing import Optional
from dataclasses import dataclass
from ..authenticators.base import BaseAuthenticator
from ..config import GeneralConfig
from ..models import CacheStatus, TrackMeta, LyricResult
@@ -38,6 +40,12 @@ class FetchResult:
class BaseFetcher(ABC):
def __init__(
self, general: GeneralConfig, auth: Optional[BaseAuthenticator] = None
) -> None:
self._general = general
self._auth = auth
@property
@abstractmethod
def source_name(self) -> str:
+1 -2
View File
@@ -13,7 +13,6 @@ from .base import BaseFetcher, FetchResult
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
TTL_UNSYNCED,
TTL_NOT_FOUND,
UA_LRX,
@@ -46,7 +45,7 @@ class LrclibFetcher(BaseFetcher):
logger.info(f"LRCLIB: fetching lyrics for {track.display_name()}")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.get(url, headers={"User-Agent": UA_LRX})
if resp.status_code == 404:
+1 -2
View File
@@ -15,7 +15,6 @@ from .selection import SearchCandidate, select_best
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
TTL_UNSYNCED,
TTL_NOT_FOUND,
UA_LRX,
@@ -73,7 +72,7 @@ class LrclibSearchFetcher(BaseFetcher):
had_error = False
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
async def _query(params: dict[str, str]) -> tuple[list[dict], bool]:
url = f"{_LRCLIB_SEARCH_URL}?{urlencode(params)}"
+14 -9
View File
@@ -18,6 +18,7 @@ from loguru import logger
from .base import BaseFetcher, FetchResult
from .selection import SearchCandidate, select_best
from ..authenticators.musixmatch import MusixmatchAuthenticator
from ..config import GeneralConfig
from ..lrc import LRCData
from ..models import CacheStatus, LyricResult, TrackMeta
@@ -145,22 +146,24 @@ async def _fetch_macro(
class MusixmatchSpotifyFetcher(BaseFetcher):
"""Direct lookup by Spotify track ID — no search, single request."""
def __init__(self, auth: MusixmatchAuthenticator) -> None:
self.auth = auth
_auth: MusixmatchAuthenticator
def __init__(self, general: GeneralConfig, auth: MusixmatchAuthenticator) -> None:
super().__init__(general, auth)
@property
def source_name(self) -> str:
return "musixmatch-spotify"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.trackid) and not self.auth.is_cooldown()
return bool(track.trackid) and not self._auth.is_cooldown()
async def fetch(self, track: TrackMeta, bypass_cache: bool = False) -> FetchResult:
logger.info(f"Musixmatch-Spotify: fetching lyrics for {track.display_name()}")
try:
lrc = await _fetch_macro(
self.auth,
self._auth,
{"track_spotify_id": track.trackid}, # type: ignore[dict-item]
)
except AttributeError:
@@ -191,8 +194,10 @@ class MusixmatchSpotifyFetcher(BaseFetcher):
class MusixmatchFetcher(BaseFetcher):
"""Metadata search + best-candidate lyric fetch."""
def __init__(self, auth: MusixmatchAuthenticator) -> None:
self.auth = auth
_auth: MusixmatchAuthenticator
def __init__(self, general: GeneralConfig, auth: MusixmatchAuthenticator) -> None:
super().__init__(general, auth)
@property
def source_name(self) -> str:
@@ -203,7 +208,7 @@ class MusixmatchFetcher(BaseFetcher):
return "musixmatch"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.title) and not self.auth.is_cooldown()
return bool(track.title) and not self._auth.is_cooldown()
async def _search(self, track: TrackMeta) -> tuple[Optional[int], float]:
"""Search for track metadata. Raises on network/HTTP errors."""
@@ -218,7 +223,7 @@ class MusixmatchFetcher(BaseFetcher):
params["q_album"] = track.album
logger.debug(f"Musixmatch: searching for '{track.display_name()}'")
data = await self.auth.get_json(_MUSIXMATCH_SEARCH_URL, params)
data = await self._auth.get_json(_MUSIXMATCH_SEARCH_URL, params)
if data is None:
return None, 0.0
@@ -270,7 +275,7 @@ class MusixmatchFetcher(BaseFetcher):
return FetchResult.from_not_found()
lrc = await _fetch_macro(
self.auth,
self._auth,
{"commontrack_id": str(commontrack_id)},
)
except AttributeError:
+2 -3
View File
@@ -16,7 +16,6 @@ from .selection import SearchCandidate, select_ranked
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
TTL_NOT_FOUND,
MULTI_CANDIDATE_DELAY_S,
UA_BROWSER,
@@ -49,7 +48,7 @@ class NeteaseFetcher(BaseFetcher):
logger.debug(f"Netease: searching for '{query}' (limit={limit})")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.post(
_NETEASE_SEARCH_URL,
headers=_NETEASE_BASE_HEADERS,
@@ -114,7 +113,7 @@ class NeteaseFetcher(BaseFetcher):
logger.debug(f"Netease: fetching lyrics for song_id={song_id}")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.post(
_NETEASE_LYRIC_URL,
headers=_NETEASE_BASE_HEADERS,
+11 -9
View File
@@ -18,7 +18,7 @@ from .selection import SearchCandidate, select_ranked
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
GeneralConfig,
TTL_NOT_FOUND,
MULTI_CANDIDATE_DELAY_S,
)
@@ -29,15 +29,17 @@ from ..authenticators import QQMusicAuthenticator
class QQMusicFetcher(BaseFetcher):
def __init__(self, auth: QQMusicAuthenticator) -> None:
self.auth = auth
_auth: QQMusicAuthenticator
def __init__(self, general: GeneralConfig, auth: QQMusicAuthenticator) -> None:
super().__init__(general, auth)
@property
def source_name(self) -> str:
return "qqmusic"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.title) and self.auth.is_configured()
return bool(track.title) and self._auth.is_configured()
async def _search(
self, track: TrackMeta, limit: int = 10
@@ -49,9 +51,9 @@ class QQMusicFetcher(BaseFetcher):
logger.debug(f"QQMusic: searching for '{query}' (limit={limit})")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.get(
f"{await self.auth.authenticate()}{_QQ_MUSIC_API_SEARCH_ENDPOINT}",
f"{await self._auth.authenticate()}{_QQ_MUSIC_API_SEARCH_ENDPOINT}",
params={"keyword": query, "type": "song", "num": limit},
)
resp.raise_for_status()
@@ -106,9 +108,9 @@ class QQMusicFetcher(BaseFetcher):
logger.debug(f"QQMusic: fetching lyrics for mid={mid}")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.get(
f"{await self.auth.authenticate()}{_QQ_MUSIC_API_LYRIC_ENDPOINT}",
f"{await self._auth.authenticate()}{_QQ_MUSIC_API_LYRIC_ENDPOINT}",
params={"mid": mid},
)
resp.raise_for_status()
@@ -154,7 +156,7 @@ class QQMusicFetcher(BaseFetcher):
return FetchResult.from_network_error()
async def fetch(self, track: TrackMeta, bypass_cache: bool = False) -> FetchResult:
if not self.auth.is_configured():
if not self._auth.is_configured():
logger.debug("QQMusic: skipped — Auth not configured")
return FetchResult()
+8 -6
View File
@@ -11,21 +11,23 @@ from .base import BaseFetcher, FetchResult
from ..authenticators.spotify import SpotifyAuthenticator, SPOTIFY_BASE_HEADERS
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import HTTP_TIMEOUT, TTL_NOT_FOUND
from ..config import GeneralConfig, TTL_NOT_FOUND
_SPOTIFY_LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/"
class SpotifyFetcher(BaseFetcher):
def __init__(self, auth: SpotifyAuthenticator) -> None:
self.auth = auth
def __init__(self, general: GeneralConfig, auth: SpotifyAuthenticator) -> None:
super().__init__(general, auth)
_auth: SpotifyAuthenticator
@property
def source_name(self) -> str:
return "spotify"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.trackid) and self.auth.is_configured()
return bool(track.trackid) and self._auth.is_configured()
@staticmethod
def _format_lrc_line(start_ms: int, words: str) -> str:
@@ -52,7 +54,7 @@ class SpotifyFetcher(BaseFetcher):
logger.info(f"Spotify: fetching lyrics for trackid={track.trackid}")
token = await self.auth.authenticate()
token = await self._auth.authenticate()
if not token:
logger.error("Spotify: cannot fetch lyrics without a token")
return FetchResult.from_network_error()
@@ -65,7 +67,7 @@ class SpotifyFetcher(BaseFetcher):
}
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
res = await client.get(url, headers=headers)
if res.status_code == 404: