This commit is contained in:
2026-03-25 05:58:37 +01:00
commit 0b9b617380
22 changed files with 2363 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
"""CLI interface for lrcfetch."""
import typer
import time
from typing import Optional
from loguru import logger
import os
from lrcfetch.config import enable_debug
from lrcfetch.models import TrackMeta, CacheStatus
from lrcfetch.mpris import get_current_track
from lrcfetch.core import LrcManager
app = typer.Typer(
help="LRCFetch — Fetch lyrics for tracks.",
add_completion=False,
)
manager = LrcManager()
@app.callback()
def main(
debug: bool = typer.Option(False, "--debug", "-d", help="Enable debug logging."),
):
if debug:
enable_debug()
# ------------------------------------------------------------------
# fetch
# ------------------------------------------------------------------
@app.command()
def fetch(
method: Optional[str] = typer.Option(
None, "--method", help="Force a specific source (local, spotify, lrclib, lrclib-search, netease)."
),
player: Optional[str] = typer.Option(
None, "--player", "-p", help="Target a specific MPRIS player."
),
no_cache: bool = typer.Option(
False, "--no-cache", help="Bypass the cache for this request."
),
only_synced: bool = typer.Option(
False, "--only-synced", help="Only accept synced (timed) lyrics."
),
):
"""Fetch and print lyrics for the currently playing track."""
track = get_current_track(player)
if not track:
logger.error("No active playing track found.")
raise typer.Exit(1)
logger.info(f"Track: {track.display_name()}")
result = manager.fetch_for_track(
track, force_method=method, bypass_cache=no_cache
)
if not result or not result.lyrics:
logger.error("No lyrics found.")
raise typer.Exit(1)
if only_synced and result.status != CacheStatus.SUCCESS_SYNCED:
logger.error("Only unsynced lyrics available (--only-synced requested).")
raise typer.Exit(1)
print(result.lyrics)
# ------------------------------------------------------------------
# search
# ------------------------------------------------------------------
@app.command()
def search(
title: str = typer.Option(..., "--title", "-t", help="Track title."),
artist: Optional[str] = typer.Option(None, "--artist", "-a", help="Artist name."),
album: Optional[str] = typer.Option(None, "--album", help="Album name."),
trackid: Optional[str] = typer.Option(None, "--trackid", help="Spotify track ID."),
length: Optional[int] = typer.Option(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(
None, "--method", help="Force a specific source."
),
no_cache: bool = typer.Option(
False, "--no-cache", help="Bypass the cache for this request."
),
only_synced: bool = typer.Option(
False, "--only-synced", help="Only accept synced (timed) lyrics."
),
):
"""Search for lyrics by metadata (bypasses MPRIS)."""
track = TrackMeta(
title=title,
artist=artist,
album=album,
trackid=trackid,
length=length,
url=url,
)
logger.info(f"Track: {track.display_name()}")
result = manager.fetch_for_track(
track, force_method=method, bypass_cache=no_cache
)
if not result or not result.lyrics:
logger.error("No lyrics found.")
raise typer.Exit(1)
if only_synced and result.status != CacheStatus.SUCCESS_SYNCED:
logger.error("Only unsynced lyrics available (--only-synced requested).")
raise typer.Exit(1)
print(result.lyrics)
# ------------------------------------------------------------------
# export
# ------------------------------------------------------------------
@app.command()
def export(
output: Optional[str] = typer.Option(
None, "--output", "-o", help="Output file path (default: <Artist> - <Title>.lrc)."
),
method: Optional[str] = typer.Option(
None, "--method", help="Force a specific source."
),
player: Optional[str] = typer.Option(
None, "--player", "-p", help="Target a specific MPRIS player."
),
no_cache: bool = typer.Option(False, "--no-cache", help="Bypass cache."),
overwrite: bool = typer.Option(
False, "--overwrite", "-f", help="Overwrite existing file."
),
):
"""Export lyrics of the current track to a .lrc file."""
track = get_current_track(player)
if not track:
logger.error("No active playing track found.")
raise typer.Exit(1)
result = manager.fetch_for_track(
track, force_method=method, bypass_cache=no_cache
)
if not result or not result.lyrics:
logger.error("No lyrics available to export.")
raise typer.Exit(1)
# Build default output path
if not output:
filename = (
f"{track.artist} - {track.title}.lrc"
if track.artist and track.title
else "lyrics.lrc"
)
# Sanitize filename
filename = "".join(
c for c in filename if c.isalpha() or c.isdigit() or c in " -_."
).rstrip()
output = os.path.join(os.getcwd(), filename)
if os.path.exists(output) and not overwrite:
logger.error(f"File exists: {output} (use -f to overwrite)")
raise typer.Exit(1)
try:
with open(output, "w", encoding="utf-8") as f:
f.write(result.lyrics)
logger.info(f"Exported lyrics to {output}")
except Exception as e:
logger.error(f"Failed to write file: {e}")
raise typer.Exit(1)
# ------------------------------------------------------------------
# cache
# ------------------------------------------------------------------
@app.command()
def cache(
clear: bool = typer.Option(False, "--clear", help="Clear the entire cache."),
clear_current: bool = typer.Option(
False, "--clear-current", help="Clear cache for the current track."
),
prune: bool = typer.Option(False, "--prune", help="Remove expired entries."),
stats: bool = typer.Option(False, "--stats", help="Show cache statistics."),
query: bool = typer.Option(
False, "--query", "-q", help="Show detailed cache info for the current track."
),
query_all: bool = typer.Option(
False, "--query-all", help="Dump all cache entries."
),
):
"""Manage the local SQLite cache."""
if clear:
manager.cache.clear_all()
return
if clear_current:
track = get_current_track()
if not track:
logger.error("No active playing track found.")
raise typer.Exit(1)
manager.cache.clear_track(track)
return
if prune:
manager.cache.prune()
return
if stats:
s = manager.cache.stats()
print("=== Cache Statistics ===")
print(f"Total entries : {s['total']}")
print(f"Active : {s['active']}")
print(f"Expired : {s['expired']}")
if s["by_status"]:
print("\nBy status:")
for status, count in s["by_status"].items():
print(f" {status}: {count}")
if s["by_source"]:
print("\nBy source:")
for source, count in s["by_source"].items():
print(f" {source}: {count}")
return
if query:
track = get_current_track()
if not track:
logger.error("No active playing track found.")
raise typer.Exit(1)
_print_track_cache(track)
return
if query_all:
rows = manager.cache.query_all()
if not rows:
print("Cache is empty.")
return
for row in rows:
_print_cache_row(row)
print()
return
logger.info(
"No action specified. Try --stats, --query, --query-all, "
"--prune, --clear, or --clear-current."
)
def _print_track_cache(track: TrackMeta) -> None:
"""Print all cached entries for a given track."""
print(f"Track: {track.display_name()}")
if track.album:
print(f"Album: {track.album}")
if track.length:
secs = track.length / 1000.0
print(f"Duration: {int(secs // 60)}:{secs % 60:05.2f}")
print()
rows = manager.cache.query_track(track)
if not rows:
print(" (no cache entries)")
return
for row in rows:
_print_cache_row(row, indent=" ")
def _print_cache_row(row: dict, indent: str = "") -> None:
"""Pretty-print a single cache row."""
now = int(time.time())
source = row.get("source", "?")
status = row.get("status", "?")
artist = row.get("artist", "")
title = row.get("title", "")
album = row.get("album", "")
created = row.get("created_at", 0)
expires = row.get("expires_at")
lyrics = row.get("lyrics", "")
name = f"{artist} - {title}" if artist and title else row.get("key", "?")
print(f"{indent}[{source}] {name}")
if album:
print(f"{indent} Album : {album}")
print(f"{indent} Status : {status}")
if created:
age = now - created
print(f"{indent} Cached : {age // 3600}h {(age % 3600) // 60}m ago")
if expires:
remaining = expires - now
if remaining > 0:
print(f"{indent} Expires : in {remaining // 3600}h {(remaining % 3600) // 60}m")
else:
print(f"{indent} Expires : EXPIRED")
else:
print(f"{indent} Expires : never")
if lyrics:
line_count = len(lyrics.splitlines())
print(f"{indent} Lyrics : {line_count} lines")
def run():
app()
if __name__ == "__main__":
run()