🚨 lint

This commit is contained in:
2026-03-27 12:52:45 +01:00
parent 6c0b61e208
commit 4182229ae2
13 changed files with 316 additions and 187 deletions
+16 -14
View File
@@ -1,13 +1,18 @@
"""SQLite-based lyric cache with per-source storage and TTL expiration.""" """
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 10:18:03
Description: SQLite-based lyric cache with per-source storage and TTL expiration
"""
import sqlite3 import sqlite3
import hashlib import hashlib
import time import time
from typing import Optional from typing import Optional
from lrcfetch.config import DB_PATH
from lrcfetch.models import TrackMeta, LyricResult, CacheStatus
from loguru import logger from loguru import logger
from .config import DB_PATH
from .models import TrackMeta, LyricResult, CacheStatus
def _generate_key(track: TrackMeta, source: str) -> str: def _generate_key(track: TrackMeta, source: str) -> str:
"""Generate a unique cache key from track metadata and source. """Generate a unique cache key from track metadata and source.
@@ -64,9 +69,7 @@ class CacheEngine:
""") """)
conn.commit() conn.commit()
# ------------------------------------------------------------------
# Read # Read
# ------------------------------------------------------------------
def get(self, track: TrackMeta, source: str) -> Optional[LyricResult]: def get(self, track: TrackMeta, source: str) -> Optional[LyricResult]:
"""Look up a cached result for *track* from *source*. """Look up a cached result for *track* from *source*.
@@ -126,9 +129,7 @@ class CacheEngine:
best = cached best = cached
return best return best
# ------------------------------------------------------------------
# Write # Write
# ------------------------------------------------------------------
def set( def set(
self, self,
@@ -171,9 +172,7 @@ class CacheEngine:
f"[{result.status.value}, ttl={ttl_seconds}s]" f"[{result.status.value}, ttl={ttl_seconds}s]"
) )
# ------------------------------------------------------------------
# Delete # Delete
# ------------------------------------------------------------------
def clear_all(self) -> None: def clear_all(self) -> None:
"""Remove every entry from the cache.""" """Remove every entry from the cache."""
@@ -193,7 +192,9 @@ class CacheEngine:
cur = conn.execute(f"DELETE FROM cache WHERE {where}", params) cur = conn.execute(f"DELETE FROM cache WHERE {where}", params)
conn.commit() conn.commit()
if cur.rowcount: if cur.rowcount:
logger.info(f"Cleared {cur.rowcount} cache entries for {track.display_name()}.") logger.info(
f"Cleared {cur.rowcount} cache entries for {track.display_name()}."
)
else: else:
logger.info(f"No cache entries found for {track.display_name()}.") logger.info(f"No cache entries found for {track.display_name()}.")
@@ -225,9 +226,7 @@ class CacheEngine:
params.append(track.album) params.append(track.album)
return conditions, params return conditions, params
# ------------------------------------------------------------------
# Query / inspect # Query / inspect
# ------------------------------------------------------------------
def query_track(self, track: TrackMeta) -> list[dict]: def query_track(self, track: TrackMeta) -> list[dict]:
"""Return all cached rows for a given track (across all sources).""" """Return all cached rows for a given track (across all sources)."""
@@ -237,9 +236,12 @@ class CacheEngine:
where = " AND ".join(conditions) where = " AND ".join(conditions)
with sqlite3.connect(self.db_path) as conn: with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
return [dict(r) for r in conn.execute( return [
dict(r)
for r in conn.execute(
f"SELECT * FROM cache WHERE {where}", params f"SELECT * FROM cache WHERE {where}", params
).fetchall()] ).fetchall()
]
def query_all(self) -> list[dict]: def query_all(self) -> list[dict]:
"""Return every row in the cache table.""" """Return every row in the cache table."""
+32 -28
View File
@@ -1,4 +1,8 @@
"""CLI interface for lrcfetch.""" """
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-26 02:04:39
Description: CLI interface
"""
import typer import typer
import time import time
@@ -6,10 +10,10 @@ from typing import Optional
from loguru import logger from loguru import logger
import os import os
from lrcfetch.config import enable_debug from .config import enable_debug
from lrcfetch.models import TrackMeta, CacheStatus from .models import TrackMeta, CacheStatus
from lrcfetch.mpris import get_current_track from .mpris import get_current_track
from lrcfetch.core import LrcManager from .core import LrcManager
app = typer.Typer( app = typer.Typer(
help="LRCFetch — Fetch line-synced lyrics for your music player.", help="LRCFetch — Fetch line-synced lyrics for your music player.",
@@ -26,7 +30,10 @@ _player: Optional[str] = None
def main( def main(
debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug logging."), debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug logging."),
player: Optional[str] = typer.Option( player: Optional[str] = typer.Option(
None, "--player", "-p", help="Target a specific MPRIS player using its DBus name or a portion thereof." None,
"--player",
"-p",
help="Target a specific MPRIS player using its DBus name or a portion thereof.",
), ),
): ):
global _player global _player
@@ -35,15 +42,15 @@ def main(
_player = player _player = player
# ------------------------------------------------------------------
# fetch # fetch
# ------------------------------------------------------------------
@app.command() @app.command()
def fetch( def fetch(
method: Optional[str] = typer.Option( method: Optional[str] = typer.Option(
None, "--method", help="Force a specific source (local, spotify, lrclib, lrclib-search, netease)." None,
"--method",
help="Force a specific source (local, spotify, lrclib, lrclib-search, netease).",
), ),
no_cache: bool = typer.Option( no_cache: bool = typer.Option(
False, "--no-cache", help="Bypass the cache for this request." False, "--no-cache", help="Bypass the cache for this request."
@@ -61,9 +68,7 @@ def fetch(
logger.info(f"Track: {track.display_name()}") logger.info(f"Track: {track.display_name()}")
result = manager.fetch_for_track( result = manager.fetch_for_track(track, force_method=method, bypass_cache=no_cache)
track, force_method=method, bypass_cache=no_cache
)
if not result or not result.lyrics: if not result or not result.lyrics:
logger.error("No lyrics found.") logger.error("No lyrics found.")
@@ -76,9 +81,7 @@ def fetch(
print(result.lyrics) print(result.lyrics)
# ------------------------------------------------------------------
# search # search
# ------------------------------------------------------------------
@app.command() @app.command()
@@ -87,8 +90,12 @@ def search(
artist: Optional[str] = typer.Option(None, "--artist", "-a", help="Artist name."), artist: Optional[str] = typer.Option(None, "--artist", "-a", help="Artist name."),
album: Optional[str] = typer.Option(None, "--album", help="Album name."), album: Optional[str] = typer.Option(None, "--album", help="Album name."),
trackid: Optional[str] = typer.Option(None, "--trackid", help="Spotify track ID."), trackid: Optional[str] = typer.Option(None, "--trackid", help="Spotify track ID."),
length: Optional[int] = typer.Option(None, "--length", "-l", help="Track duration in milliseconds."), length: Optional[int] = typer.Option(
url: Optional[str] = typer.Option(None, "--url", help="Local file URL (file:///...)."), None, "--length", "-l", help="Track duration in milliseconds."
),
url: Optional[str] = typer.Option(
None, "--url", help="Local file URL (file:///...)."
),
method: Optional[str] = typer.Option( method: Optional[str] = typer.Option(
None, "--method", help="Force a specific source." None, "--method", help="Force a specific source."
), ),
@@ -111,9 +118,7 @@ def search(
logger.info(f"Track: {track.display_name()}") logger.info(f"Track: {track.display_name()}")
result = manager.fetch_for_track( result = manager.fetch_for_track(track, force_method=method, bypass_cache=no_cache)
track, force_method=method, bypass_cache=no_cache
)
if not result or not result.lyrics: if not result or not result.lyrics:
logger.error("No lyrics found.") logger.error("No lyrics found.")
@@ -126,15 +131,16 @@ def search(
print(result.lyrics) print(result.lyrics)
# ------------------------------------------------------------------
# export # export
# ------------------------------------------------------------------
@app.command() @app.command()
def export( def export(
output: Optional[str] = typer.Option( output: Optional[str] = typer.Option(
None, "--output", "-o", help="Output file path (default: <Artist> - <Title>.lrc)." None,
"--output",
"-o",
help="Output file path (default: <Artist> - <Title>.lrc).",
), ),
method: Optional[str] = typer.Option( method: Optional[str] = typer.Option(
None, "--method", help="Force a specific source." None, "--method", help="Force a specific source."
@@ -150,9 +156,7 @@ def export(
logger.error("No active playing track found.") logger.error("No active playing track found.")
raise typer.Exit(1) raise typer.Exit(1)
result = manager.fetch_for_track( result = manager.fetch_for_track(track, force_method=method, bypass_cache=no_cache)
track, force_method=method, bypass_cache=no_cache
)
if not result or not result.lyrics: if not result or not result.lyrics:
logger.error("No lyrics available to export.") logger.error("No lyrics available to export.")
raise typer.Exit(1) raise typer.Exit(1)
@@ -183,9 +187,7 @@ def export(
raise typer.Exit(1) raise typer.Exit(1)
# ------------------------------------------------------------------
# cache # cache
# ------------------------------------------------------------------
@app.command() @app.command()
@@ -302,7 +304,9 @@ def _print_cache_row(row: dict, indent: str = "") -> None:
if expires: if expires:
remaining = expires - now remaining = expires - now
if remaining > 0: if remaining > 0:
print(f"{indent} Expires : in {remaining // 3600}h {(remaining % 3600) // 60}m") print(
f"{indent} Expires : in {remaining // 3600}h {(remaining % 3600) // 60}m"
)
else: else:
print(f"{indent} Expires : EXPIRED") print(f"{indent} Expires : EXPIRED")
else: else:
+17 -16
View File
@@ -1,4 +1,8 @@
"""Global configuration constants and logger setup.""" """
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 10:17:56
Description: Global configuration constants and logger setup
"""
import os import os
import sys import sys
@@ -7,32 +11,32 @@ from platformdirs import user_cache_dir, user_config_dir
from dotenv import load_dotenv from dotenv import load_dotenv
from loguru import logger from loguru import logger
# ─── Application ───────────────────────────────────────────────────── # Application
APP_NAME = "lrcfetch" APP_NAME = "lrcfetch"
APP_AUTHOR = "Uyanide" APP_AUTHOR = "Uyanide"
# ─── Paths ─────────────────────────────────────────────────────────── # Paths
CACHE_DIR = user_cache_dir(APP_NAME, APP_AUTHOR) CACHE_DIR = user_cache_dir(APP_NAME, APP_AUTHOR)
DB_PATH = os.path.join(CACHE_DIR, "cache.db") DB_PATH = os.path.join(CACHE_DIR, "cache.db")
# ─── .env loading (XDG config dir first, then project-local) ───────── # .env loading
_config_env = Path(user_config_dir(APP_NAME, APP_AUTHOR)) / ".env" _config_env = Path(user_config_dir(APP_NAME, APP_AUTHOR)) / ".env"
load_dotenv(_config_env) # ~/.config/lrcfetch/.env load_dotenv(_config_env) # ~/.config/lrcfetch/.env
load_dotenv() # .env in cwd (does NOT override existing vars) load_dotenv() # .env in cwd (does NOT override existing vars)
# ─── HTTP ──────────────────────────────────────────────────────────── # HTTP
HTTP_TIMEOUT = 10.0 HTTP_TIMEOUT = 10.0
# ─── Cache TTLs (seconds) ─────────────────────────────────────────── # Cache TTLs (seconds)
TTL_SYNCED = None # never expires TTL_SYNCED = None # never expires
TTL_UNSYNCED = 86400 # 1 day TTL_UNSYNCED = 86400 # 1 day
TTL_NOT_FOUND = 86400 * 3 # 3 days TTL_NOT_FOUND = 86400 * 3 # 3 days
TTL_NETWORK_ERROR = 3600 # 1 hour TTL_NETWORK_ERROR = 3600 # 1 hour
# ─── Search ────────────────────────────────────────────────────────── # Search
DURATION_TOLERANCE_MS = 3000 # max duration mismatch for search matching DURATION_TOLERANCE_MS = 3000 # max duration mismatch for search matching
# ─── Spotify ───────────────────────────────────────────────────────── # Spotify related
SPOTIFY_TOKEN_URL = "https://open.spotify.com/api/token" SPOTIFY_TOKEN_URL = "https://open.spotify.com/api/token"
SPOTIFY_LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/" SPOTIFY_LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/"
SPOTIFY_SERVER_TIME_URL = "https://open.spotify.com/api/server-time" SPOTIFY_SERVER_TIME_URL = "https://open.spotify.com/api/server-time"
@@ -43,24 +47,21 @@ SPOTIFY_SECRET_URL = (
SPOTIFY_SP_DC = os.environ.get("SPOTIFY_SP_DC", "") SPOTIFY_SP_DC = os.environ.get("SPOTIFY_SP_DC", "")
SPOTIFY_TOKEN_CACHE_FILE = os.path.join(CACHE_DIR, "spotify_token.json") SPOTIFY_TOKEN_CACHE_FILE = os.path.join(CACHE_DIR, "spotify_token.json")
# ─── Netease ───────────────────────────────────────────────────────── # Netease api
NETEASE_SEARCH_URL = "https://music.163.com/api/cloudsearch/pc" NETEASE_SEARCH_URL = "https://music.163.com/api/cloudsearch/pc"
NETEASE_LYRIC_URL = "https://interface3.music.163.com/api/song/lyric" NETEASE_LYRIC_URL = "https://interface3.music.163.com/api/song/lyric"
# ─── LRCLIB ────────────────────────────────────────────────────────── # LRCLIB api
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"
# ─── User-Agents ───────────────────────────────────────────────────── # User-Agents
UA_BROWSER = ( UA_BROWSER = "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0"
"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)"
os.makedirs(CACHE_DIR, exist_ok=True) os.makedirs(CACHE_DIR, exist_ok=True)
# ─── Logger ────────────────────────────────────────────────────────── # Logger
_LOG_FORMAT = ( _LOG_FORMAT = (
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | " "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <8}</level> | " "<level>{level: <8}</level> | "
+29 -17
View File
@@ -1,5 +1,10 @@
"""Core orchestrator — coordinates fetchers with cache-aware fallback. """
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 11:09:53
Description: Core orchestrator — coordinates fetchers with cache-aware fallback
"""
"""
Fetch pipeline: Fetch pipeline:
1. Check cache for each source in the fallback sequence 1. Check cache for each source in the fallback sequence
2. For sources without a valid cache hit, call the fetcher 2. For sources without a valid cache hit, call the fetcher
@@ -9,16 +14,18 @@ Fetch pipeline:
from typing import Optional from typing import Optional
from loguru import logger from loguru import logger
from lrcfetch.models import TrackMeta, LyricResult, CacheStatus
from lrcfetch.config import TTL_SYNCED, TTL_UNSYNCED, TTL_NOT_FOUND, TTL_NETWORK_ERROR from .fetchers.netease import NeteaseFetcher
from lrcfetch.lrc import LRC_LINE_RE, normalize_tags from .fetchers.lrclib_search import LrclibSearchFetcher
from lrcfetch.cache import CacheEngine from .fetchers.lrclib import LrclibFetcher
from lrcfetch.fetchers.base import BaseFetcher from .fetchers.spotify import SpotifyFetcher
from lrcfetch.fetchers.local import LocalFetcher from .fetchers.local import LocalFetcher
from lrcfetch.fetchers.spotify import SpotifyFetcher from .fetchers.base import BaseFetcher
from lrcfetch.fetchers.lrclib import LrclibFetcher from .cache import CacheEngine
from lrcfetch.fetchers.lrclib_search import LrclibSearchFetcher from .lrc import LRC_LINE_RE, normalize_tags
from lrcfetch.fetchers.netease import NeteaseFetcher from .config import TTL_SYNCED, TTL_UNSYNCED, TTL_NOT_FOUND, TTL_NETWORK_ERROR
from .models import TrackMeta, LyricResult, CacheStatus
def _normalize_unsynced(lyrics: str) -> str: def _normalize_unsynced(lyrics: str) -> str:
"""Normalize unsynced lyrics so every line has a [00:00.00] tag. """Normalize unsynced lyrics so every line has a [00:00.00] tag.
@@ -83,9 +90,7 @@ class LrcManager:
sequence.append(self.fetchers["lrclib-search"]) sequence.append(self.fetchers["lrclib-search"])
sequence.append(self.fetchers["netease"]) sequence.append(self.fetchers["netease"])
logger.debug( logger.debug(f"Fallback sequence: {[f.source_name for f in sequence]}")
f"Fallback sequence: {[f.source_name for f in sequence]}"
)
return sequence return sequence
def fetch_for_track( def fetch_for_track(
@@ -124,12 +129,19 @@ class LrcManager:
logger.info(f"[{source}] cache hit: synced lyrics") logger.info(f"[{source}] cache hit: synced lyrics")
return cached return cached
elif cached.status == CacheStatus.SUCCESS_UNSYNCED: elif cached.status == CacheStatus.SUCCESS_UNSYNCED:
logger.debug(f"[{source}] cache hit: unsynced lyrics (continuing)") logger.debug(
f"[{source}] cache hit: unsynced lyrics (continuing)"
)
if best_result is None: if best_result is None:
best_result = cached best_result = cached
continue # Try next source for synced continue # Try next source for synced
elif cached.status in (CacheStatus.NOT_FOUND, CacheStatus.NETWORK_ERROR): elif cached.status in (
logger.debug(f"[{source}] cache hit: {cached.status.value}, skipping") CacheStatus.NOT_FOUND,
CacheStatus.NETWORK_ERROR,
):
logger.debug(
f"[{source}] cache hit: {cached.status.value}, skipping"
)
continue continue
else: else:
logger.debug(f"[{source}] cache bypassed") logger.debug(f"[{source}] cache bypassed")
+8 -1
View File
@@ -1,6 +1,13 @@
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 02:33:26
Description: Base fetcher class and common interfaces
"""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional from typing import Optional
from lrcfetch.models import TrackMeta, LyricResult
from ..models import TrackMeta, LyricResult
class BaseFetcher(ABC): class BaseFetcher(ABC):
+13 -5
View File
@@ -1,5 +1,10 @@
"""Local fetcher — reads lyrics from .lrc sidecar files or embedded audio metadata. """
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-26 02:08:41
Description: Local fetcher — reads lyrics from .lrc sidecar files or embedded audio metadata
"""
"""
Priority: Priority:
1. Same-directory .lrc file (e.g. /path/to/track.lrc) 1. Same-directory .lrc file (e.g. /path/to/track.lrc)
2. Embedded lyrics in audio metadata (FLAC, MP3 USLT/SYLT tags) 2. Embedded lyrics in audio metadata (FLAC, MP3 USLT/SYLT tags)
@@ -9,12 +14,13 @@ import os
from typing import Optional from typing import Optional
from urllib.parse import unquote from urllib.parse import unquote
from loguru import logger from loguru import logger
from lrcfetch.models import TrackMeta, LyricResult, CacheStatus
from lrcfetch.fetchers.base import BaseFetcher
from lrcfetch.lrc import detect_sync_status
from mutagen._file import File from mutagen._file import File
from mutagen.flac import FLAC from mutagen.flac import FLAC
from .base import BaseFetcher
from ..models import TrackMeta, LyricResult
from ..lrc import detect_sync_status
class LocalFetcher(BaseFetcher): class LocalFetcher(BaseFetcher):
@property @property
@@ -56,7 +62,9 @@ class LocalFetcher(BaseFetcher):
if isinstance(audio, FLAC): if isinstance(audio, FLAC):
# FLAC stores lyrics in vorbis comment tags # FLAC stores lyrics in vorbis comment tags
lyrics = (audio.get("lyrics") or audio.get("unsynclyrics") or [None])[0] lyrics = (
audio.get("lyrics") or audio.get("unsynclyrics") or [None]
)[0]
elif hasattr(audio, "tags") and audio.tags: elif hasattr(audio, "tags") and audio.tags:
# MP3 / other: look for USLT or SYLT ID3 frames # MP3 / other: look for USLT or SYLT ID3 frames
for key in audio.tags.keys(): for key in audio.tags.keys():
+16 -7
View File
@@ -1,16 +1,21 @@
"""LRCLIB fetcher — queries lrclib.net for synced/plain lyrics. """
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 05:23:38
Description: LRCLIB fetcher — queries lrclib.net for synced/plain lyrics
"""
"""
Requires complete track metadata (artist, title, album, duration). Requires complete track metadata (artist, title, album, duration).
""" """
import httpx
from typing import Optional from typing import Optional
import httpx
from loguru import logger from loguru import logger
from urllib.parse import urlencode from urllib.parse import urlencode
from lrcfetch.models import TrackMeta, LyricResult, CacheStatus from .base import BaseFetcher
from lrcfetch.fetchers.base import BaseFetcher from ..models import TrackMeta, LyricResult, CacheStatus
from lrcfetch.config import ( from ..config import (
HTTP_TIMEOUT, HTTP_TIMEOUT,
TTL_UNSYNCED, TTL_UNSYNCED,
TTL_NOT_FOUND, TTL_NOT_FOUND,
@@ -51,14 +56,18 @@ class LrclibFetcher(BaseFetcher):
if resp.status_code != 200: if resp.status_code != 200:
logger.error(f"LRCLIB: API returned {resp.status_code}") logger.error(f"LRCLIB: API returned {resp.status_code}")
return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR) return LyricResult(
status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR
)
data = resp.json() data = resp.json()
# Validate response # Validate response
if not isinstance(data, dict): if not isinstance(data, dict):
logger.error(f"LRCLIB: unexpected response type: {type(data).__name__}") logger.error(f"LRCLIB: unexpected response type: {type(data).__name__}")
return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR) return LyricResult(
status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR
)
synced = data.get("syncedLyrics") synced = data.get("syncedLyrics")
unsynced = data.get("plainLyrics") unsynced = data.get("plainLyrics")
+35 -11
View File
@@ -1,5 +1,10 @@
"""LRCLIB search fetcher — fuzzy search via lrclib.net /api/search. """
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 05:30:50
Description: LRCLIB search fetcher — fuzzy search via lrclib.net /api/search
"""
"""
Used when metadata is incomplete (no album or duration) but title is available. Used when metadata is incomplete (no album or duration) but title is available.
Selects the best match by duration when track length is known. Selects the best match by duration when track length is known.
""" """
@@ -9,9 +14,9 @@ from typing import Optional
from loguru import logger from loguru import logger
from urllib.parse import urlencode from urllib.parse import urlencode
from lrcfetch.models import TrackMeta, LyricResult, CacheStatus from .base import BaseFetcher
from lrcfetch.fetchers.base import BaseFetcher from ..models import TrackMeta, LyricResult, CacheStatus
from lrcfetch.config import ( from ..config import (
HTTP_TIMEOUT, HTTP_TIMEOUT,
TTL_UNSYNCED, TTL_UNSYNCED,
TTL_NOT_FOUND, TTL_NOT_FOUND,
@@ -48,7 +53,9 @@ class LrclibSearchFetcher(BaseFetcher):
if resp.status_code != 200: if resp.status_code != 200:
logger.error(f"LRCLIB-search: API returned {resp.status_code}") logger.error(f"LRCLIB-search: API returned {resp.status_code}")
return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR) return LyricResult(
status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR
)
data = resp.json() data = resp.json()
@@ -116,21 +123,38 @@ class LrclibSearchFetcher(BaseFetcher):
if diff > DURATION_TOLERANCE_MS: if diff > DURATION_TOLERANCE_MS:
continue continue
# Prefer synced over unsynced at similar duration # Prefer synced over unsynced at similar duration
has_synced = isinstance(item.get("syncedLyrics"), str) and item["syncedLyrics"].strip() has_synced = (
best_synced = best is not None and isinstance(best.get("syncedLyrics"), str) and best["syncedLyrics"].strip() isinstance(item.get("syncedLyrics"), str)
if diff < best_diff or (diff == best_diff and has_synced and not best_synced): and item["syncedLyrics"].strip()
)
best_synced = (
best is not None
and isinstance(best.get("syncedLyrics"), str)
and best["syncedLyrics"].strip()
)
if diff < best_diff or (
diff == best_diff and has_synced and not best_synced
):
best_diff = diff best_diff = diff
best = item best = item
if best is not None: if best is not None:
logger.debug(f"LRCLIB-search: selected id={best.get('id')} (diff={best_diff:.0f}ms)") logger.debug(
f"LRCLIB-search: selected id={best.get('id')} (diff={best_diff:.0f}ms)"
)
return best return best
logger.debug(f"LRCLIB-search: no candidate within {DURATION_TOLERANCE_MS}ms") logger.debug(
f"LRCLIB-search: no candidate within {DURATION_TOLERANCE_MS}ms"
)
return None return None
# No duration — pick first with synced lyrics, or just first # No duration — pick first with synced lyrics, or just first
for item in candidates: for item in candidates:
if isinstance(item, dict) and isinstance(item.get("syncedLyrics"), str) and item["syncedLyrics"].strip(): if (
isinstance(item, dict)
and isinstance(item.get("syncedLyrics"), str)
and item["syncedLyrics"].strip()
):
return item return item
return candidates[0] if isinstance(candidates[0], dict) else None return candidates[0] if isinstance(candidates[0], dict) else None
+32 -16
View File
@@ -1,5 +1,10 @@
"""Netease Cloud Music fetcher. """
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 11:04:51
Description: Netease Cloud Music fetcher
"""
"""
Uses the public cloudsearch API for searching and the song/lyric API for Uses the public cloudsearch API for searching and the song/lyric API for
retrieving lyrics. No authentication required. retrieving lyrics. No authentication required.
@@ -7,13 +12,14 @@ Search results are filtered by duration when the track has a known length
to avoid returning lyrics for the wrong version of a song. to avoid returning lyrics for the wrong version of a song.
""" """
import httpx
from typing import Optional from typing import Optional
import httpx
from loguru import logger from loguru import logger
from lrcfetch.models import TrackMeta, LyricResult, CacheStatus
from lrcfetch.fetchers.base import BaseFetcher from .base import BaseFetcher
from lrcfetch.lrc import is_synced from ..models import TrackMeta, LyricResult, CacheStatus
from lrcfetch.config import ( from ..lrc import is_synced
from ..config import (
HTTP_TIMEOUT, HTTP_TIMEOUT,
TTL_NOT_FOUND, TTL_NOT_FOUND,
TTL_NETWORK_ERROR, TTL_NETWORK_ERROR,
@@ -58,12 +64,14 @@ class NeteaseFetcher(BaseFetcher):
# Validate response # Validate response
if not isinstance(result, dict): if not isinstance(result, dict):
logger.error(f"Netease: search returned non-dict: {type(result).__name__}") logger.error(
f"Netease: search returned non-dict: {type(result).__name__}"
)
return None return None
result_body = result.get("result") result_body = result.get("result")
if not isinstance(result_body, dict): if not isinstance(result_body, dict):
logger.debug(f"Netease: search 'result' field missing or invalid") logger.debug("Netease: search 'result' field missing or invalid")
return None return None
songs = result_body.get("songs") songs = result_body.get("songs")
@@ -86,7 +94,9 @@ class NeteaseFetcher(BaseFetcher):
name = song.get("name", "?") name = song.get("name", "?")
duration = song.get("dt") # milliseconds duration = song.get("dt") # milliseconds
if not isinstance(duration, int): if not isinstance(duration, int):
logger.debug(f" candidate {sid} '{name}': no duration, skipped") logger.debug(
f" candidate {sid} '{name}': no duration, skipped"
)
continue continue
diff = abs(duration - track_ms) diff = abs(duration - track_ms)
logger.debug( logger.debug(
@@ -98,9 +108,7 @@ class NeteaseFetcher(BaseFetcher):
best_id = sid best_id = sid
if best_id is not None and best_diff <= DURATION_TOLERANCE_MS: if best_id is not None and best_diff <= DURATION_TOLERANCE_MS:
logger.debug( logger.debug(f"Netease: selected id={best_id} (diff={best_diff}ms)")
f"Netease: selected id={best_id} (diff={best_diff}ms)"
)
return best_id return best_id
logger.debug( logger.debug(
@@ -150,12 +158,18 @@ class NeteaseFetcher(BaseFetcher):
# Validate response # Validate response
if not isinstance(data, dict): if not isinstance(data, dict):
logger.error(f"Netease: lyric response is not dict: {type(data).__name__}") logger.error(
return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR) f"Netease: lyric response is not dict: {type(data).__name__}"
)
return LyricResult(
status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR
)
lrc_obj = data.get("lrc") lrc_obj = data.get("lrc")
if not isinstance(lrc_obj, dict): if not isinstance(lrc_obj, dict):
logger.debug(f"Netease: no 'lrc' object in response for song_id={song_id}") logger.debug(
f"Netease: no 'lrc' object in response for song_id={song_id}"
)
return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND) return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND)
lrc: str = lrc_obj.get("lyric", "") lrc: str = lrc_obj.get("lyric", "")
@@ -165,7 +179,9 @@ class NeteaseFetcher(BaseFetcher):
# Determine sync status # Determine sync status
synced = is_synced(lrc) synced = is_synced(lrc)
status = CacheStatus.SUCCESS_SYNCED if synced else CacheStatus.SUCCESS_UNSYNCED status = (
CacheStatus.SUCCESS_SYNCED if synced else CacheStatus.SUCCESS_UNSYNCED
)
logger.info( logger.info(
f"Netease: got {status.value} lyrics for song_id={song_id} " f"Netease: got {status.value} lyrics for song_id={song_id} "
f"({len(lrc.splitlines())} lines)" f"({len(lrc.splitlines())} lines)"
+25 -20
View File
@@ -1,8 +1,13 @@
"""Spotify fetcher — obtains synced lyrics via Spotify's internal color-lyrics API. """
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 10:43:21
Description: Spotify fetcher — obtains synced lyrics via Spotify's internal color-lyrics API.
"""
Authentication flow (mirrors spotify-lyrics Go implementation): """
Authentication flow:
1. Fetch server time from Spotify 1. Fetch server time from Spotify
2. Fetch TOTP secret from xyloflake/spot-secrets-go 2. Fetch TOTP secret
3. Generate a TOTP code and exchange it (with SP_DC cookie) for an access token 3. Generate a TOTP code and exchange it (with SP_DC cookie) for an access token
4. Request lyrics using the access token 4. Request lyrics using the access token
@@ -12,8 +17,8 @@ calls within the same session.
Requires SPOTIFY_SP_DC environment variable to be set. Requires SPOTIFY_SP_DC environment variable to be set.
""" """
import json
import httpx import httpx
import json
import time import time
import struct import struct
import hmac import hmac
@@ -21,9 +26,9 @@ import hashlib
from typing import Optional, Tuple from typing import Optional, Tuple
from loguru import logger from loguru import logger
from lrcfetch.models import TrackMeta, LyricResult, CacheStatus from .base import BaseFetcher
from lrcfetch.fetchers.base import BaseFetcher from ..models import TrackMeta, LyricResult, CacheStatus
from lrcfetch.config import ( from ..config import (
HTTP_TIMEOUT, HTTP_TIMEOUT,
TTL_NOT_FOUND, TTL_NOT_FOUND,
TTL_NETWORK_ERROR, TTL_NETWORK_ERROR,
@@ -83,7 +88,8 @@ class SpotifyFetcher(BaseFetcher):
if not isinstance(data, list) or len(data) == 0: if not isinstance(data, list) or len(data) == 0:
logger.error( logger.error(
f"Spotify: unexpected secrets response (type={type(data).__name__}, len={len(data) if isinstance(data, list) else '?'})") f"Spotify: unexpected secrets response (type={type(data).__name__}, len={len(data) if isinstance(data, list) else '?'})"
)
return None return None
last = data[-1] last = data[-1]
@@ -210,16 +216,15 @@ class SpotifyFetcher(BaseFetcher):
try: try:
res = client.get(SPOTIFY_TOKEN_URL, params=params, timeout=HTTP_TIMEOUT) res = client.get(SPOTIFY_TOKEN_URL, params=params, timeout=HTTP_TIMEOUT)
if res.status_code != 200: if res.status_code != 200:
logger.error( logger.error(f"Spotify: token request returned {res.status_code}")
f"Spotify: token request returned {res.status_code}"
)
return None return None
body = res.json() body = res.json()
if not isinstance(body, dict) or "accessToken" not in body: if not isinstance(body, dict) or "accessToken" not in body:
logger.error( logger.error(
f"Spotify: unexpected token response keys: {list(body.keys()) if isinstance(body, dict) else type(body).__name__}") f"Spotify: unexpected token response keys: {list(body.keys()) if isinstance(body, dict) else type(body).__name__}"
)
return None return None
token = body["accessToken"] token = body["accessToken"]
@@ -294,9 +299,7 @@ class SpotifyFetcher(BaseFetcher):
if res.status_code == 404: if res.status_code == 404:
logger.debug(f"Spotify: 404 for trackid={track.trackid}") logger.debug(f"Spotify: 404 for trackid={track.trackid}")
return LyricResult( return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND)
status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND
)
if res.status_code != 200: if res.status_code != 200:
logger.error(f"Spotify: lyrics API returned {res.status_code}") logger.error(f"Spotify: lyrics API returned {res.status_code}")
@@ -308,7 +311,7 @@ class SpotifyFetcher(BaseFetcher):
# Validate response structure # Validate response structure
if not isinstance(data, dict) or "lyrics" not in data: if not isinstance(data, dict) or "lyrics" not in data:
logger.error(f"Spotify: unexpected lyrics response structure") logger.error("Spotify: unexpected lyrics response structure")
return LyricResult( return LyricResult(
status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR
) )
@@ -343,11 +346,13 @@ class SpotifyFetcher(BaseFetcher):
lrc_lines.append(f"[00:00.00]{words}") lrc_lines.append(f"[00:00.00]{words}")
content = "\n".join(lrc_lines) content = "\n".join(lrc_lines)
status = CacheStatus.SUCCESS_SYNCED if is_synced else CacheStatus.SUCCESS_UNSYNCED status = (
CacheStatus.SUCCESS_SYNCED
logger.info( if is_synced
f"Spotify: got {status.value} lyrics ({len(lrc_lines)} lines)" else CacheStatus.SUCCESS_UNSYNCED
) )
logger.info(f"Spotify: got {status.value} lyrics ({len(lrc_lines)} lines)")
return LyricResult(status=status, lyrics=content, source=self.source_name) return LyricResult(status=status, lyrics=content, source=self.source_name)
except Exception as e: except Exception as e:
+9 -5
View File
@@ -1,10 +1,12 @@
"""Shared LRC time-tag utilities. """
Author: Uyanide pywang0608@foxmail.com
Handles detection, normalization, and sync-status checks for LRC lyrics. Date: 2026-03-25 21:54:01
Description: Shared LRC time-tag utilities
""" """
import re import re
from lrcfetch.models import CacheStatus
from .models import CacheStatus
# Standard format: [mm:ss.cc] or [mm:ss.ccc] # Standard format: [mm:ss.cc] or [mm:ss.ccc]
_STANDARD_TAG_RE = re.compile(r"\[\d{2}:\d{2}\.\d{2,3}\]") _STANDARD_TAG_RE = re.compile(r"\[\d{2}:\d{2}\.\d{2,3}\]")
@@ -88,4 +90,6 @@ def is_synced(text: str) -> bool:
def detect_sync_status(text: str) -> CacheStatus: def detect_sync_status(text: str) -> CacheStatus:
"""Determine whether lyrics contain meaningful LRC time tags.""" """Determine whether lyrics contain meaningful LRC time tags."""
return CacheStatus.SUCCESS_SYNCED if is_synced(text) else CacheStatus.SUCCESS_UNSYNCED return (
CacheStatus.SUCCESS_SYNCED if is_synced(text) else CacheStatus.SUCCESS_UNSYNCED
)
+8 -1
View File
@@ -1,4 +1,8 @@
"""Data models for lrcfetch.""" """
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 04:09:36
Description: Data models
"""
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
from enum import Enum from enum import Enum
@@ -7,6 +11,7 @@ from typing import Optional
class CacheStatus(str, Enum): class CacheStatus(str, Enum):
"""Status of a cached lyric entry.""" """Status of a cached lyric entry."""
SUCCESS_SYNCED = "SUCCESS_SYNCED" SUCCESS_SYNCED = "SUCCESS_SYNCED"
SUCCESS_UNSYNCED = "SUCCESS_UNSYNCED" SUCCESS_UNSYNCED = "SUCCESS_UNSYNCED"
NOT_FOUND = "NOT_FOUND" NOT_FOUND = "NOT_FOUND"
@@ -15,6 +20,7 @@ class CacheStatus(str, Enum):
class TrackMeta(BaseModel): class TrackMeta(BaseModel):
"""Metadata describing a track obtained from MPRIS or manual input.""" """Metadata describing a track obtained from MPRIS or manual input."""
model_config = ConfigDict(strict=True) model_config = ConfigDict(strict=True)
trackid: Optional[str] = None # Spotify track ID (without "spotify:track:" prefix) trackid: Optional[str] = None # Spotify track ID (without "spotify:track:" prefix)
@@ -46,6 +52,7 @@ class TrackMeta(BaseModel):
class LyricResult(BaseModel): class LyricResult(BaseModel):
"""Result of a lyric fetch attempt, also used as cache record.""" """Result of a lyric fetch attempt, also used as cache record."""
model_config = ConfigDict(strict=True) model_config = ConfigDict(strict=True)
status: CacheStatus status: CacheStatus
+42 -12
View File
@@ -1,3 +1,9 @@
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 04:44:15
Description: MPRIS integration for fetching track metadata
"""
import asyncio import asyncio
from dbus_next.aio.message_bus import MessageBus from dbus_next.aio.message_bus import MessageBus
from dbus_next.constants import BusType from dbus_next.constants import BusType
@@ -7,14 +13,17 @@ from loguru import logger
from typing import Optional, List, Any from typing import Optional, List, Any
import subprocess import subprocess
async def _get_active_players(bus: MessageBus, specific_player: Optional[str] = None) -> List[str]:
async def _get_active_players(
bus: MessageBus, specific_player: Optional[str] = None
) -> List[str]:
try: try:
reply = await bus.call( reply = await bus.call(
Message( Message(
destination="org.freedesktop.DBus", destination="org.freedesktop.DBus",
path="/org/freedesktop/DBus", path="/org/freedesktop/DBus",
interface="org.freedesktop.DBus", interface="org.freedesktop.DBus",
member="ListNames" member="ListNames",
) )
) )
if not reply or not reply.body: if not reply or not reply.body:
@@ -34,7 +43,10 @@ async def _get_active_players(bus: MessageBus, specific_player: Optional[str] =
logger.error(f"Failed to list DBus names: {e}") logger.error(f"Failed to list DBus names: {e}")
return [] return []
async def _fetch_metadata_dbus(specific_player: Optional[str] = None) -> Optional[TrackMeta]:
async def _fetch_metadata_dbus(
specific_player: Optional[str] = None,
) -> Optional[TrackMeta]:
bus = None bus = None
try: try:
bus = await MessageBus(bus_type=BusType.SESSION).connect() bus = await MessageBus(bus_type=BusType.SESSION).connect()
@@ -45,14 +57,18 @@ async def _fetch_metadata_dbus(specific_player: Optional[str] = None) -> Optiona
try: try:
players = await _get_active_players(bus, specific_player) players = await _get_active_players(bus, specific_player)
if not players: if not players:
logger.debug(f"No active MPRIS players found via DBus{' for ' + specific_player if specific_player else ''}.") logger.debug(
f"No active MPRIS players found via DBus{' for ' + specific_player if specific_player else ''}."
)
return None return None
player_name = players[0] player_name = players[0]
logger.debug(f"Using player: {player_name}") logger.debug(f"Using player: {player_name}")
introspection = await bus.introspect(player_name, "/org/mpris/MediaPlayer2") introspection = await bus.introspect(player_name, "/org/mpris/MediaPlayer2")
proxy = bus.get_proxy_object(player_name, "/org/mpris/MediaPlayer2", introspection) proxy = bus.get_proxy_object(
player_name, "/org/mpris/MediaPlayer2", introspection
)
props_iface = proxy.get_interface("org.freedesktop.DBus.Properties") props_iface = proxy.get_interface("org.freedesktop.DBus.Properties")
if not props_iface: if not props_iface:
@@ -60,7 +76,9 @@ async def _fetch_metadata_dbus(specific_player: Optional[str] = None) -> Optiona
return None return None
try: try:
metadata_var: Any = await getattr(props_iface, "call_get")("org.mpris.MediaPlayer2.Player", "Metadata") metadata_var: Any = await getattr(props_iface, "call_get")(
"org.mpris.MediaPlayer2.Player", "Metadata"
)
if not metadata_var: if not metadata_var:
logger.error("Empty metadata received.") logger.error("Empty metadata received.")
return None return None
@@ -87,7 +105,11 @@ async def _fetch_metadata_dbus(specific_player: Optional[str] = None) -> Optiona
album = album.value if album else None album = album.value if album else None
artist = metadata.get("xesam:artist", None) artist = metadata.get("xesam:artist", None)
artist = artist.value[0] if artist and isinstance(artist.value, list) and artist.value else None artist = (
artist.value[0]
if artist and isinstance(artist.value, list) and artist.value
else None
)
title = metadata.get("xesam:title", None) title = metadata.get("xesam:title", None)
title = title.value if title else None title = title.value if title else None
@@ -101,7 +123,7 @@ async def _fetch_metadata_dbus(specific_player: Optional[str] = None) -> Optiona
album=album, album=album,
artist=artist, artist=artist,
title=title, title=title,
url=url url=url,
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to get properties from {player_name}: {e}") logger.error(f"Failed to get properties from {player_name}: {e}")
@@ -111,7 +133,10 @@ async def _fetch_metadata_dbus(specific_player: Optional[str] = None) -> Optiona
if bus: if bus:
bus.disconnect() bus.disconnect()
def _fetch_metadata_subprocess(specific_player: Optional[str] = None) -> Optional[TrackMeta]:
def _fetch_metadata_subprocess(
specific_player: Optional[str] = None,
) -> Optional[TrackMeta]:
"""Fallback using playerctl if dbus-next fails or session bus is problematic.""" """Fallback using playerctl if dbus-next fails or session bus is problematic."""
logger.debug("Attempting to use playerctl as fallback.") logger.debug("Attempting to use playerctl as fallback.")
try: try:
@@ -123,7 +148,9 @@ def _fetch_metadata_subprocess(specific_player: Optional[str] = None) -> Optiona
base_cmd.extend(["-p", specific_player]) base_cmd.extend(["-p", specific_player])
def _get_prop(prop: str) -> Optional[str]: def _get_prop(prop: str) -> Optional[str]:
res = subprocess.run(base_cmd + ["metadata", prop], capture_output=True, text=True) res = subprocess.run(
base_cmd + ["metadata", prop], capture_output=True, text=True
)
if res.returncode == 0 and res.stdout.strip(): if res.returncode == 0 and res.stdout.strip():
return res.stdout.strip() return res.stdout.strip()
return None return None
@@ -136,7 +163,9 @@ def _fetch_metadata_subprocess(specific_player: Optional[str] = None) -> Optiona
trackid = trackid.removeprefix("/com/spotify/track/") trackid = trackid.removeprefix("/com/spotify/track/")
length_str = _get_prop("mpris:length") length_str = _get_prop("mpris:length")
length = int(length_str) // 1000 if length_str and length_str.isdigit() else None length = (
int(length_str) // 1000 if length_str and length_str.isdigit() else None
)
album = _get_prop("xesam:album") album = _get_prop("xesam:album")
artist = _get_prop("xesam:artist") artist = _get_prop("xesam:artist")
@@ -152,12 +181,13 @@ def _fetch_metadata_subprocess(specific_player: Optional[str] = None) -> Optiona
album=album, album=album,
artist=artist, artist=artist,
title=title, title=title,
url=url url=url,
) )
except Exception as e: except Exception as e:
logger.debug(f"playerctl fallback failed: {e}") logger.debug(f"playerctl fallback failed: {e}")
return None return None
def get_current_track(player_name: Optional[str] = None) -> Optional[TrackMeta]: def get_current_track(player_name: Optional[str] = None) -> Optional[TrackMeta]:
try: try:
meta = asyncio.run(_fetch_metadata_dbus(player_name)) meta = asyncio.run(_fetch_metadata_dbus(player_name))