🚨 lint

This commit is contained in:
2026-03-27 12:52:45 +01:00
parent 23b2d5ae20
commit 8ba9daf968
13 changed files with 316 additions and 187 deletions
+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 typing import Optional
from lrcfetch.models import TrackMeta, LyricResult
from ..models import TrackMeta, LyricResult
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:
1. Same-directory .lrc file (e.g. /path/to/track.lrc)
2. Embedded lyrics in audio metadata (FLAC, MP3 USLT/SYLT tags)
@@ -9,12 +14,13 @@ import os
from typing import Optional
from urllib.parse import unquote
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.flac import FLAC
from .base import BaseFetcher
from ..models import TrackMeta, LyricResult
from ..lrc import detect_sync_status
class LocalFetcher(BaseFetcher):
@property
@@ -56,7 +62,9 @@ class LocalFetcher(BaseFetcher):
if isinstance(audio, FLAC):
# 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:
# MP3 / other: look for USLT or SYLT ID3 frames
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).
"""
import httpx
from typing import Optional
import httpx
from loguru import logger
from urllib.parse import urlencode
from lrcfetch.models import TrackMeta, LyricResult, CacheStatus
from lrcfetch.fetchers.base import BaseFetcher
from lrcfetch.config import (
from .base import BaseFetcher
from ..models import TrackMeta, LyricResult, CacheStatus
from ..config import (
HTTP_TIMEOUT,
TTL_UNSYNCED,
TTL_NOT_FOUND,
@@ -51,14 +56,18 @@ class LrclibFetcher(BaseFetcher):
if resp.status_code != 200:
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()
# Validate response
if not isinstance(data, dict):
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")
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.
Selects the best match by duration when track length is known.
"""
@@ -9,9 +14,9 @@ from typing import Optional
from loguru import logger
from urllib.parse import urlencode
from lrcfetch.models import TrackMeta, LyricResult, CacheStatus
from lrcfetch.fetchers.base import BaseFetcher
from lrcfetch.config import (
from .base import BaseFetcher
from ..models import TrackMeta, LyricResult, CacheStatus
from ..config import (
HTTP_TIMEOUT,
TTL_UNSYNCED,
TTL_NOT_FOUND,
@@ -48,7 +53,9 @@ class LrclibSearchFetcher(BaseFetcher):
if resp.status_code != 200:
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()
@@ -116,21 +123,38 @@ class LrclibSearchFetcher(BaseFetcher):
if diff > DURATION_TOLERANCE_MS:
continue
# Prefer synced over unsynced at similar duration
has_synced = isinstance(item.get("syncedLyrics"), str) 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):
has_synced = (
isinstance(item.get("syncedLyrics"), str)
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 = item
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
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
# No duration — pick first with synced lyrics, or just first
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 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
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.
"""
import httpx
from typing import Optional
import httpx
from loguru import logger
from lrcfetch.models import TrackMeta, LyricResult, CacheStatus
from lrcfetch.fetchers.base import BaseFetcher
from lrcfetch.lrc import is_synced
from lrcfetch.config import (
from .base import BaseFetcher
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import is_synced
from ..config import (
HTTP_TIMEOUT,
TTL_NOT_FOUND,
TTL_NETWORK_ERROR,
@@ -58,12 +64,14 @@ class NeteaseFetcher(BaseFetcher):
# Validate response
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
result_body = result.get("result")
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
songs = result_body.get("songs")
@@ -86,7 +94,9 @@ class NeteaseFetcher(BaseFetcher):
name = song.get("name", "?")
duration = song.get("dt") # milliseconds
if not isinstance(duration, int):
logger.debug(f" candidate {sid} '{name}': no duration, skipped")
logger.debug(
f" candidate {sid} '{name}': no duration, skipped"
)
continue
diff = abs(duration - track_ms)
logger.debug(
@@ -98,9 +108,7 @@ class NeteaseFetcher(BaseFetcher):
best_id = sid
if best_id is not None and best_diff <= DURATION_TOLERANCE_MS:
logger.debug(
f"Netease: selected id={best_id} (diff={best_diff}ms)"
)
logger.debug(f"Netease: selected id={best_id} (diff={best_diff}ms)")
return best_id
logger.debug(
@@ -150,12 +158,18 @@ class NeteaseFetcher(BaseFetcher):
# Validate response
if not isinstance(data, dict):
logger.error(f"Netease: lyric response is not dict: {type(data).__name__}")
return LyricResult(status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR)
logger.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")
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)
lrc: str = lrc_obj.get("lyric", "")
@@ -165,7 +179,9 @@ class NeteaseFetcher(BaseFetcher):
# Determine sync status
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(
f"Netease: got {status.value} lyrics for song_id={song_id} "
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
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
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.
"""
import json
import httpx
import json
import time
import struct
import hmac
@@ -21,9 +26,9 @@ import hashlib
from typing import Optional, Tuple
from loguru import logger
from lrcfetch.models import TrackMeta, LyricResult, CacheStatus
from lrcfetch.fetchers.base import BaseFetcher
from lrcfetch.config import (
from .base import BaseFetcher
from ..models import TrackMeta, LyricResult, CacheStatus
from ..config import (
HTTP_TIMEOUT,
TTL_NOT_FOUND,
TTL_NETWORK_ERROR,
@@ -83,7 +88,8 @@ class SpotifyFetcher(BaseFetcher):
if not isinstance(data, list) or len(data) == 0:
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
last = data[-1]
@@ -210,16 +216,15 @@ class SpotifyFetcher(BaseFetcher):
try:
res = client.get(SPOTIFY_TOKEN_URL, params=params, timeout=HTTP_TIMEOUT)
if res.status_code != 200:
logger.error(
f"Spotify: token request returned {res.status_code}"
)
logger.error(f"Spotify: token request returned {res.status_code}")
return None
body = res.json()
if not isinstance(body, dict) or "accessToken" not in body:
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
token = body["accessToken"]
@@ -294,9 +299,7 @@ class SpotifyFetcher(BaseFetcher):
if res.status_code == 404:
logger.debug(f"Spotify: 404 for trackid={track.trackid}")
return LyricResult(
status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND
)
return LyricResult(status=CacheStatus.NOT_FOUND, ttl=TTL_NOT_FOUND)
if res.status_code != 200:
logger.error(f"Spotify: lyrics API returned {res.status_code}")
@@ -308,7 +311,7 @@ class SpotifyFetcher(BaseFetcher):
# Validate response structure
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(
status=CacheStatus.NETWORK_ERROR, ttl=TTL_NETWORK_ERROR
)
@@ -343,11 +346,13 @@ class SpotifyFetcher(BaseFetcher):
lrc_lines.append(f"[00:00.00]{words}")
content = "\n".join(lrc_lines)
status = CacheStatus.SUCCESS_SYNCED if is_synced else CacheStatus.SUCCESS_UNSYNCED
logger.info(
f"Spotify: got {status.value} lyrics ({len(lrc_lines)} lines)"
status = (
CacheStatus.SUCCESS_SYNCED
if is_synced
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)
except Exception as e: