🚨 lint
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user