100 lines
3.6 KiB
Python
100 lines
3.6 KiB
Python
"""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)
|
|
"""
|
|
|
|
import re
|
|
import os
|
|
from typing import Optional
|
|
from loguru import logger
|
|
from lrcfetch.models import TrackMeta, LyricResult, CacheStatus
|
|
from lrcfetch.fetchers.base import BaseFetcher
|
|
from mutagen._file import File
|
|
from mutagen.flac import FLAC
|
|
|
|
# Matches LRC time tags like [00:12.34] or [01:23.456]
|
|
_LRC_TIME_TAG_RE = re.compile(r"\[\d{2}:\d{2}\.\d{2,3}\]")
|
|
# Matches time tags that are all zeros
|
|
_ZERO_TIME_TAG_RE = re.compile(r"^\[00:00\.0{2,3}\]$")
|
|
|
|
|
|
def _detect_sync_status(text: str) -> CacheStatus:
|
|
"""Determine whether lyrics text contains meaningful LRC time tags.
|
|
|
|
Returns UNSYNCED if no tags exist or all tags are [00:00.00].
|
|
"""
|
|
tags = _LRC_TIME_TAG_RE.findall(text)
|
|
if not tags:
|
|
return CacheStatus.SUCCESS_UNSYNCED
|
|
for tag in tags:
|
|
if not _ZERO_TIME_TAG_RE.match(tag):
|
|
return CacheStatus.SUCCESS_SYNCED
|
|
return CacheStatus.SUCCESS_UNSYNCED
|
|
|
|
|
|
class LocalFetcher(BaseFetcher):
|
|
@property
|
|
def source_name(self) -> str:
|
|
return "local"
|
|
|
|
def fetch(self, track: TrackMeta) -> Optional[LyricResult]:
|
|
"""Attempt to read lyrics from local filesystem."""
|
|
if not track.is_local or not track.url:
|
|
return None
|
|
|
|
file_path = track.url.replace("file://", "", 1)
|
|
if not os.path.exists(file_path):
|
|
logger.debug(f"Local: file does not exist: {file_path}")
|
|
return None
|
|
|
|
logger.info(f"Local: checking for lyrics near {file_path}")
|
|
|
|
# Sidecar .lrc file
|
|
lrc_path = os.path.splitext(file_path)[0] + ".lrc"
|
|
if os.path.exists(lrc_path):
|
|
try:
|
|
with open(lrc_path, "r", encoding="utf-8") as f:
|
|
content = f.read().strip()
|
|
if content:
|
|
status = _detect_sync_status(content)
|
|
logger.info(f"Local: found .lrc sidecar ({status.value})")
|
|
return LyricResult(
|
|
status=status, lyrics=content, source=self.source_name
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Local: error reading {lrc_path}: {e}")
|
|
|
|
# Embedded metadata
|
|
try:
|
|
audio = File(file_path)
|
|
if audio is not None:
|
|
lyrics = None
|
|
|
|
if isinstance(audio, FLAC):
|
|
# FLAC stores lyrics in vorbis comment tags
|
|
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():
|
|
if key.startswith("USLT") or key.startswith("SYLT"):
|
|
lyrics = str(audio.tags[key])
|
|
break
|
|
|
|
if lyrics:
|
|
status = _detect_sync_status(lyrics)
|
|
logger.info(f"Local: found embedded lyrics ({status.value})")
|
|
return LyricResult(
|
|
status=status,
|
|
lyrics=lyrics.strip(),
|
|
source=f"{self.source_name} (embedded)",
|
|
)
|
|
else:
|
|
logger.debug("Local: no embedded lyrics found")
|
|
except Exception as e:
|
|
logger.error(f"Local: error reading metadata for {file_path}: {e}")
|
|
|
|
logger.debug(f"Local: no lyrics found for {file_path}")
|
|
return None
|