feat: export lyrics default to sidecar path
This commit is contained in:
@@ -1 +1 @@
|
|||||||
__version__ = "0.1.3"
|
__version__ = "0.1.4"
|
||||||
|
|||||||
+10
-1
@@ -15,6 +15,7 @@ from .config import enable_debug
|
|||||||
from .models import TrackMeta, CacheStatus
|
from .models import TrackMeta, CacheStatus
|
||||||
from .mpris import get_current_track
|
from .mpris import get_current_track
|
||||||
from .core import LrcManager, FetcherMethodType
|
from .core import LrcManager, FetcherMethodType
|
||||||
|
from .lrc import get_sidecar_path
|
||||||
|
|
||||||
|
|
||||||
app = cyclopts.App(
|
app = cyclopts.App(
|
||||||
@@ -174,7 +175,7 @@ def export(
|
|||||||
str | None,
|
str | None,
|
||||||
cyclopts.Parameter(
|
cyclopts.Parameter(
|
||||||
name=["--output", "-o"],
|
name=["--output", "-o"],
|
||||||
help="Output file path (default: <Artist> - <Title>.lrc).",
|
help="Output file path (default: same directory as audio file with .lrc extension, or current directory if not available).",
|
||||||
),
|
),
|
||||||
] = None,
|
] = None,
|
||||||
method: Annotated[
|
method: Annotated[
|
||||||
@@ -202,6 +203,14 @@ def export(
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Build default output path
|
# Build default output path
|
||||||
|
if not output:
|
||||||
|
if track.url:
|
||||||
|
lrc_path = get_sidecar_path(track.url, ensure_exists=False)
|
||||||
|
if lrc_path:
|
||||||
|
output = str(lrc_path)
|
||||||
|
logger.info(f"Exporting to sidecar path: {output}")
|
||||||
|
|
||||||
|
# Fallback to current directory with sanitized filename
|
||||||
if not output:
|
if not output:
|
||||||
filename = (
|
filename = (
|
||||||
f"{track.artist} - {track.title}.lrc"
|
f"{track.artist} - {track.title}.lrc"
|
||||||
|
|||||||
+16
-14
@@ -10,16 +10,14 @@ Priority:
|
|||||||
2. Embedded lyrics in audio metadata (FLAC, MP3 USLT/SYLT tags)
|
2. Embedded lyrics in audio metadata (FLAC, MP3 USLT/SYLT tags)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import unquote
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
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 .base import BaseFetcher
|
||||||
from ..models import TrackMeta, LyricResult
|
from ..models import TrackMeta, LyricResult
|
||||||
from ..lrc import detect_sync_status
|
from ..lrc import detect_sync_status, get_audio_path, get_sidecar_path
|
||||||
|
|
||||||
|
|
||||||
class LocalFetcher(BaseFetcher):
|
class LocalFetcher(BaseFetcher):
|
||||||
@@ -32,16 +30,15 @@ class LocalFetcher(BaseFetcher):
|
|||||||
if not track.is_local or not track.url:
|
if not track.is_local or not track.url:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
file_path = unquote(track.url.replace("file://", "", 1))
|
audio_path = get_audio_path(track.url, ensure_exists=False)
|
||||||
if not os.path.exists(file_path):
|
if not audio_path:
|
||||||
logger.debug(f"Local: file does not exist: {file_path}")
|
logger.debug(f"Local: audio URL is not a valid file path: {track.url}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
logger.info(f"Local: checking for lyrics near {file_path}")
|
lrc_path = get_sidecar_path(
|
||||||
|
track.url, ensure_audio_exists=False, ensure_exists=True
|
||||||
# Sidecar .lrc file
|
)
|
||||||
lrc_path = os.path.splitext(file_path)[0] + ".lrc"
|
if lrc_path:
|
||||||
if os.path.exists(lrc_path):
|
|
||||||
try:
|
try:
|
||||||
with open(lrc_path, "r", encoding="utf-8") as f:
|
with open(lrc_path, "r", encoding="utf-8") as f:
|
||||||
content = f.read().strip()
|
content = f.read().strip()
|
||||||
@@ -53,10 +50,15 @@ class LocalFetcher(BaseFetcher):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Local: error reading {lrc_path}: {e}")
|
logger.error(f"Local: error reading {lrc_path}: {e}")
|
||||||
|
else:
|
||||||
|
logger.debug(f"Local: no .lrc sidecar found for {audio_path}")
|
||||||
|
|
||||||
# Embedded metadata
|
# Embedded metadata
|
||||||
|
if not audio_path.exists():
|
||||||
|
logger.debug(f"Local: audio file does not exist: {audio_path}")
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
audio = File(file_path)
|
audio = File(audio_path)
|
||||||
if audio is not None:
|
if audio is not None:
|
||||||
lyrics = None
|
lyrics = None
|
||||||
|
|
||||||
@@ -83,7 +85,7 @@ class LocalFetcher(BaseFetcher):
|
|||||||
else:
|
else:
|
||||||
logger.debug("Local: no embedded lyrics found")
|
logger.debug("Local: no embedded lyrics found")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Local: error reading metadata for {file_path}: {e}")
|
logger.error(f"Local: error reading metadata for {audio_path}: {e}")
|
||||||
|
|
||||||
logger.debug(f"Local: no lyrics found for {file_path}")
|
logger.debug(f"Local: no lyrics found for {audio_path}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ Description: Shared LRC time-tag utilities
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from .models import CacheStatus
|
from .models import CacheStatus
|
||||||
|
|
||||||
@@ -93,3 +96,31 @@ def detect_sync_status(text: str) -> CacheStatus:
|
|||||||
return (
|
return (
|
||||||
CacheStatus.SUCCESS_SYNCED if is_synced(text) else CacheStatus.SUCCESS_UNSYNCED
|
CacheStatus.SUCCESS_SYNCED if is_synced(text) else CacheStatus.SUCCESS_UNSYNCED
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_audio_path(audio_url: str, ensure_exists: bool = False) -> Optional[Path]:
|
||||||
|
"""Convert file:// URL to Path, return None if invalid or (if ensure_exists) file doesn't exist."""
|
||||||
|
if not audio_url.startswith("file://"):
|
||||||
|
return None
|
||||||
|
file_path = unquote(audio_url.replace("file://", "", 1))
|
||||||
|
path = Path(file_path)
|
||||||
|
if ensure_exists and not path.exists():
|
||||||
|
return None
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def get_sidecar_path(
|
||||||
|
audio_url: str, ensure_audio_exists: bool = False, ensure_exists: bool = False
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""Given a file:// URL, return the corresponding .lrc sidecar path.
|
||||||
|
|
||||||
|
If ensure_audio_exists is True, return None if the audio file does not exist.
|
||||||
|
If ensure_exists is True, return None if the .lrc file does not exist.
|
||||||
|
"""
|
||||||
|
audio_path = get_audio_path(audio_url, ensure_exists=ensure_audio_exists)
|
||||||
|
if not audio_path:
|
||||||
|
return None
|
||||||
|
lrc_path = audio_path.with_suffix(".lrc")
|
||||||
|
if ensure_exists and not lrc_path.exists():
|
||||||
|
return None
|
||||||
|
return lrc_path
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "lrcfetch"
|
name = "lrcfetch"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user