init
This commit is contained in:
+318
@@ -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()
|
||||
Reference in New Issue
Block a user