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:
@@ -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,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user