Compare commits

...

3 Commits

Author SHA1 Message Date
Uyanide 67bc4e7d7a feat: add insert subcommand to cache command 2026-03-31 18:08:40 +02:00
Uyanide d3e0e289a6 chore: 0.1.7 2026-03-31 18:08:40 +02:00
Uyanide e8d9299939 refactor: add is_available method to fetchers 2026-03-31 18:08:40 +02:00
12 changed files with 81 additions and 15 deletions
+31
View File
@@ -330,6 +330,37 @@ def stats():
print(f" {source}: {count}") print(f" {source}: {count}")
@cache_app.command
def insert(
*,
path: Annotated[
str | None,
cyclopts.Parameter(
name=["--path"],
help="Path to a local .lrc file to insert instead of reading from stdin.",
),
] = None,
):
"""Manually insert lyrics into the cache for the current track."""
track = get_current_track(_player)
if not track:
logger.error("No active playing track found.")
sys.exit(1)
if path:
try:
with open(path, "r", encoding="utf-8") as f:
lyrics = f.read()
except Exception as e:
logger.error(f"Failed to read file: {e}")
sys.exit(1)
else:
logger.info("Reading lyrics from stdin (Ctrl+D to finish)...")
lyrics = sys.stdin.read()
manager.manual_insert(track, lyrics)
# helpers # helpers
+22 -13
View File
@@ -18,7 +18,7 @@ from loguru import logger
from .fetchers import FetcherMethodType, create_fetchers from .fetchers import FetcherMethodType, create_fetchers
from .fetchers.base import BaseFetcher from .fetchers.base import BaseFetcher
from .cache import CacheEngine from .cache import CacheEngine
from .lrc import normalize_unsynced from .lrc import normalize_tags, normalize_unsynced, detect_sync_status
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
from .enrichers import enrich_track from .enrichers import enrich_track
@@ -51,18 +51,9 @@ class LrcManager:
return [self.fetchers[force_method]] return [self.fetchers[force_method]]
sequence: list[BaseFetcher] = [] sequence: list[BaseFetcher] = []
if track.is_local: for method in self.fetchers.keys():
sequence.append(self.fetchers["local"]) if self.fetchers[method].is_available(track):
if track.title: sequence.append(self.fetchers[method])
sequence.append(self.fetchers["cache-search"])
if track.trackid:
sequence.append(self.fetchers["spotify"])
if track.is_complete:
sequence.append(self.fetchers["lrclib"])
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]}") logger.debug(f"Fallback sequence: {[f.source_name for f in sequence]}")
return sequence return sequence
@@ -167,3 +158,21 @@ class LrcManager:
logger.info(f"No lyrics found for {track.display_name()}") logger.info(f"No lyrics found for {track.display_name()}")
return best_result return best_result
def manual_insert(
self,
track: TrackMeta,
lyrics: str,
) -> None:
"""Manually insert lyrics into the cache for a track."""
track = enrich_track(track)
logger.info(f"Manually inserting lyrics for: {track.display_name()}")
lyrics = normalize_tags(lyrics)
result = LyricResult(
status=detect_sync_status(lyrics),
lyrics=normalize_tags(lyrics),
source="manual",
ttl=None,
)
self.cache.set(track, "manual", result, ttl_seconds=None)
logger.info("Lyrics inserted into cache.")
+5
View File
@@ -22,6 +22,11 @@ class BaseFetcher(ABC):
"""True if this fetcher manages its own cache (skip per-source cache check).""" """True if this fetcher manages its own cache (skip per-source cache check)."""
return False return False
@abstractmethod
def is_available(self, track: TrackMeta) -> bool:
"""Check if the fetcher is available for the given track (e.g. has required metadata)."""
pass
@abstractmethod @abstractmethod
def fetch( def fetch(
self, track: TrackMeta, bypass_cache: bool = False self, track: TrackMeta, bypass_cache: bool = False
+3
View File
@@ -30,6 +30,9 @@ class CacheSearchFetcher(BaseFetcher):
def self_cached(self) -> bool: def self_cached(self) -> bool:
return True return True
def is_available(self, track: TrackMeta) -> bool:
return bool(track.title)
def fetch( def fetch(
self, track: TrackMeta, bypass_cache: bool = False self, track: TrackMeta, bypass_cache: bool = False
) -> Optional[LyricResult]: ) -> Optional[LyricResult]:
+3
View File
@@ -25,6 +25,9 @@ class LocalFetcher(BaseFetcher):
def source_name(self) -> str: def source_name(self) -> str:
return "local" return "local"
def is_available(self, track: TrackMeta) -> bool:
return track.is_local
def fetch( def fetch(
self, track: TrackMeta, bypass_cache: bool = False self, track: TrackMeta, bypass_cache: bool = False
) -> Optional[LyricResult]: ) -> Optional[LyricResult]:
+3
View File
@@ -31,6 +31,9 @@ class LrclibFetcher(BaseFetcher):
def source_name(self) -> str: def source_name(self) -> str:
return "lrclib" return "lrclib"
def is_available(self, track: TrackMeta) -> bool:
return track.is_complete
def fetch( def fetch(
self, track: TrackMeta, bypass_cache: bool = False self, track: TrackMeta, bypass_cache: bool = False
) -> Optional[LyricResult]: ) -> Optional[LyricResult]:
+3
View File
@@ -33,6 +33,9 @@ class LrclibSearchFetcher(BaseFetcher):
def source_name(self) -> str: def source_name(self) -> str:
return "lrclib-search" return "lrclib-search"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.title)
def fetch( def fetch(
self, track: TrackMeta, bypass_cache: bool = False self, track: TrackMeta, bypass_cache: bool = False
) -> Optional[LyricResult]: ) -> Optional[LyricResult]:
+3
View File
@@ -40,6 +40,9 @@ class NeteaseFetcher(BaseFetcher):
def source_name(self) -> str: def source_name(self) -> str:
return "netease" return "netease"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.title)
def _search(self, track: TrackMeta, limit: int = 10) -> Optional[int]: def _search(self, track: TrackMeta, limit: int = 10) -> Optional[int]:
"""Search Netease and return the best-matching song ID. """Search Netease and return the best-matching song ID.
+3
View File
@@ -32,6 +32,9 @@ class QQMusicFetcher(BaseFetcher):
def source_name(self) -> str: def source_name(self) -> str:
return "qqmusic" return "qqmusic"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.title) and bool(QQ_MUSIC_API_URL)
def _search(self, track: TrackMeta, limit: int = 10) -> Optional[str]: def _search(self, track: TrackMeta, limit: int = 10) -> Optional[str]:
"""Search QQ Music and return the best-matching song MID.""" """Search QQ Music and return the best-matching song MID."""
query = f"{track.artist or ''} {track.title or ''}".strip() query = f"{track.artist or ''} {track.title or ''}".strip()
+3
View File
@@ -55,6 +55,9 @@ class SpotifyFetcher(BaseFetcher):
def source_name(self) -> str: def source_name(self) -> str:
return "spotify" return "spotify"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.trackid) and bool(SPOTIFY_SP_DC)
# ─── Auth helpers ──────────────────────────────────────────────── # ─── Auth helpers ────────────────────────────────────────────────
def _get_server_time(self, client: httpx.Client) -> Optional[int]: def _get_server_time(self, client: httpx.Client) -> Optional[int]:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "lrcfetch" name = "lrcfetch"
version = "0.1.6" version = "0.1.7"
description = "Fetch line-synced lyrics for your music player." description = "Fetch line-synced lyrics for your music player."
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
Generated
+1 -1
View File
@@ -153,7 +153,7 @@ wheels = [
[[package]] [[package]]
name = "lrcfetch" name = "lrcfetch"
version = "0.1.6" version = "0.1.7"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "cyclopts" }, { name = "cyclopts" },