feat: add metadata enrichers & refactor

This commit is contained in:
2026-03-31 06:08:16 +02:00
parent d76b25e250
commit 4e83e6be15
19 changed files with 363 additions and 60 deletions
+54
View File
@@ -0,0 +1,54 @@
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 02:33:26
Description: Fetcher pipeline — registry and types
"""
from typing import Literal
from .base import BaseFetcher
from .local import LocalFetcher
from .cache_search import CacheSearchFetcher
from .spotify import SpotifyFetcher
from .lrclib import LrclibFetcher
from .lrclib_search import LrclibSearchFetcher
from .netease import NeteaseFetcher
from .qqmusic import QQMusicFetcher
from ..cache import CacheEngine
METHODS = (
"local",
"cache-search",
"spotify",
"lrclib",
"lrclib-search",
"netease",
"qqmusic",
)
FetcherMethodType = Literal[
"local",
"cache-search",
"spotify",
"lrclib",
"lrclib-search",
"netease",
"qqmusic",
]
def create_fetchers(cache: CacheEngine) -> dict[str, BaseFetcher]:
"""Instantiate all fetchers. Returns a dict keyed by source name."""
fetchers: dict[str, BaseFetcher] = {
"local": LocalFetcher(),
"cache-search": CacheSearchFetcher(cache),
"spotify": SpotifyFetcher(),
"lrclib": LrclibFetcher(),
"lrclib-search": LrclibSearchFetcher(),
"netease": NeteaseFetcher(),
"qqmusic": QQMusicFetcher(),
}
assert set(fetchers) == set(METHODS), (
f"METHODS and fetchers out of sync: {set(METHODS) ^ set(fetchers)}"
)
return fetchers
+8 -1
View File
@@ -17,7 +17,14 @@ class BaseFetcher(ABC):
"""Name of the fetcher source."""
pass
@property
def self_cached(self) -> bool:
"""True if this fetcher manages its own cache (skip per-source cache check)."""
return False
@abstractmethod
def fetch(self, track: TrackMeta) -> Optional[LyricResult]:
def fetch(
self, track: TrackMeta, bypass_cache: bool = False
) -> Optional[LyricResult]:
"""Fetch lyrics for the given track. Returns None if unable to fetch."""
pass
+11 -1
View File
@@ -26,7 +26,17 @@ class CacheSearchFetcher(BaseFetcher):
def source_name(self) -> str:
return "cache-search"
def fetch(self, track: TrackMeta) -> Optional[LyricResult]:
@property
def self_cached(self) -> bool:
return True
def fetch(
self, track: TrackMeta, bypass_cache: bool = False
) -> Optional[LyricResult]:
if bypass_cache:
logger.debug("Cache-search: bypassed by caller")
return None
if not track.title:
logger.debug("Cache-search: skipped — no title")
return None
+3 -1
View File
@@ -25,7 +25,9 @@ class LocalFetcher(BaseFetcher):
def source_name(self) -> str:
return "local"
def fetch(self, track: TrackMeta) -> Optional[LyricResult]:
def fetch(
self, track: TrackMeta, bypass_cache: bool = False
) -> Optional[LyricResult]:
"""Attempt to read lyrics from local filesystem."""
if not track.is_local or not track.url:
return None
+3 -1
View File
@@ -30,7 +30,9 @@ class LrclibFetcher(BaseFetcher):
def source_name(self) -> str:
return "lrclib"
def fetch(self, track: TrackMeta) -> Optional[LyricResult]:
def fetch(
self, track: TrackMeta, bypass_cache: bool = False
) -> Optional[LyricResult]:
"""Fetch lyrics from LRCLIB. Requires complete metadata."""
if not track.is_complete:
logger.debug("LRCLIB: skipped — incomplete metadata")
+3 -1
View File
@@ -32,7 +32,9 @@ class LrclibSearchFetcher(BaseFetcher):
def source_name(self) -> str:
return "lrclib-search"
def fetch(self, track: TrackMeta) -> Optional[LyricResult]:
def fetch(
self, track: TrackMeta, bypass_cache: bool = False
) -> Optional[LyricResult]:
"""Search LRCLIB for lyrics. Requires at least a title."""
if not track.title:
logger.debug("LRCLIB-search: skipped — no title")
+3 -1
View File
@@ -194,7 +194,9 @@ class NeteaseFetcher(BaseFetcher):
logger.error(f"Netease: lyric fetch failed for song_id={song_id}: {e}")
return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR)
def fetch(self, track: TrackMeta) -> Optional[LyricResult]:
def fetch(
self, track: TrackMeta, bypass_cache: bool = False
) -> Optional[LyricResult]:
"""Search for the track and fetch its lyrics."""
query = f"{track.artist or ''} {track.title or ''}".strip()
if not query:
+3 -1
View File
@@ -155,7 +155,9 @@ class QQMusicFetcher(BaseFetcher):
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]:
def fetch(
self, track: TrackMeta, bypass_cache: bool = False
) -> 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")
+3 -1
View File
@@ -274,7 +274,9 @@ class SpotifyFetcher(BaseFetcher):
continue
return False
def fetch(self, track: TrackMeta) -> Optional[LyricResult]:
def fetch(
self, track: TrackMeta, bypass_cache: bool = False
) -> Optional[LyricResult]:
"""Fetch lyrics for a Spotify track by its track ID."""
if not track.trackid:
logger.debug("Spotify: skipped — no trackid in metadata")