feat: config file

refactor: as the config module changed
test: add test for config
test: add test for local fetcher and local enrichers
test: add test for manual insertion
fix: some random bugs left by the last commit
This commit is contained in:
2026-04-09 15:16:21 +02:00
parent e6b8583868
commit d2a3e64b89
34 changed files with 749 additions and 413 deletions
+52 -31
View File
@@ -21,7 +21,7 @@ highest-confidence result wins.
1. **Local** — sidecar `.lrc` files or embedded audio metadata (FLAC, MP3)
2. **Cache Search** — fuzzy cross-album lookup in local cache
3. **Spotify** — synced lyrics via Spotify's API
(requires `SPOTIFY_SP_DC` and Spotify trackid)
(requires `credentials.spotify_sp_dc` and Spotify trackid)
4. **LRCLIB** — exact match from [lrclib.net](https://lrclib.net)
(requires full metadata)
5. **Musixmatch (Spotify)** — Musixmatch API with Spotify trackid
@@ -30,7 +30,7 @@ highest-confidence result wins.
7. **Musixmatch** — Musixmatch API with metadata search (requires at least a title)
8. **Netease** — Netease Cloud Music public API
9. **QQ Music** — QQ Music via self-hosted API proxy
(requires `QQ_MUSIC_API_URL` that provides the same interface as [tooplick/qq-music-api](https://github.com/tooplick/qq-music-api))
(requires `credentials.qq_music_api_url`; compatible with [tooplick/qq-music-api](https://github.com/tooplick/qq-music-api))
> I'm aware that Spotify's lyrics are provided by Musixmatch, but the fact is
> that Musixmatch's own search will yield different (and more) results than
@@ -46,7 +46,7 @@ See `lrx --help` for full command reference. Common use cases:
lrx fetch
```
targeting a specific player and a source to fetch from:
targeting a specific player and source:
```bash
lrx fetch --player mpd --method lrclib-search
@@ -73,6 +73,21 @@ See `lrx --help` for full command reference. Common use cases:
lrx export --output /path/to/lyrics.lrc
```
- Watch active player and stream lyrics continuously to stdout:
```bash
lrx watch pipe
lrx watch pipe --before 1 --after 2 # show context lines
```
Control a running watch session:
```bash
lrx watch ctl status # print session status as JSON
lrx watch ctl offset +200 # shift lyrics forward 200 ms
lrx watch ctl offset -150
```
- Cache management:
```bash
@@ -83,40 +98,46 @@ See `lrx --help` for full command reference. Common use cases:
lrx cache confidence spotify 100 # manually set confidence for a source
```
## Configuration
Set credentials via environment variable or `.env` file:
- `~/.config/lrx/.env` — user-level
- `.env` in working directory — project-local
- Shell environment — highest priority
```env
SPOTIFY_SP_DC=your_cookie_value
MUSIXMATCH_USERTOKEN=your_musixmatch_usertoken
QQ_MUSIC_API_URL=https://api.example.com
PREFERRED_PLAYER=spotify
```
- `SPOTIFY_SP_DC` — required for Spotify source. Defaults to empty
(disabled Spotify source).
- `MUSIXMATCH_USERTOKEN` — optional for Musixmatch sources
([Curators Settings Page](https://curators.musixmatch.com/settings)
-> Login (if required)
-> "Copy debug info").
If not set, an anonymous token will be fetched at runtime.
- `QQ_MUSIC_API_URL` — required for QQ Music source. Defaults to empty
(disabled QQ Music source).
- `PREFERRED_PLAYER` — preferred MPRIS player when multiple are active.
Defaults to `spotify`. Only used when no `--player` flag is given and more
than one player (or none of them) is currently playing.
Shell completion (zsh/fish/bash):
```bash
lrx --install-completion
```
## Configuration
Configuration is read from `~/.config/lrx-cli/config.toml`. The file is
optional; all values have defaults. Unknown keys are rejected with an error.
```toml
[general]
preferred_player = "spotify" # preferred MPRIS player when multiple are active
player_blacklist = ["firefox", "zen", "chrome", "chromium", "vivaldi", "edge", "opera", "mpv"]
http_timeout = 10.0 # seconds
[credentials]
spotify_sp_dc = "" # required for Spotify source
musixmatch_usertoken = "" # optional; anonymous token fetched if empty
qq_music_api_url = "" # required for QQ Music source
[watch]
debounce_ms = 400 # ms to wait after a track change before fetching
calibration_interval_s = 3.0 # seconds between full MPRIS position recalibrations
position_tick_ms = 50 # ms between local position ticks
socket_path = "" # Unix socket path; defaults to <cache_dir>/watch.sock
```
**Credentials:**
- `spotify_sp_dc` — `SP_DC` cookie from a logged-in Spotify web session. Required
for the Spotify source; leave empty to disable it.
- `musixmatch_usertoken` — found at
[Curators Settings Page](https://curators.musixmatch.com/settings) → Login → "Copy debug info".
If empty, an anonymous token is fetched at runtime.
- `qq_music_api_url` — base URL of a self-hosted
[qq-music-api](https://github.com/tooplick/qq-music-api) (compatible) instance. Required
for the QQ Music source; leave empty to disable it.
## Development
Clone this repository:
+2 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "lrx-cli"
version = "0.6.4"
version = "0.6.5"
description = "Fetch line-synced lyrics for your music player."
readme = "README.md"
requires-python = ">=3.13"
@@ -14,8 +14,7 @@ dependencies = [
"httpx>=0.28.1",
"loguru>=0.7.3",
"mutagen>=1.47.0",
"platformdirs>=4.9.4",
"python-dotenv>=1.2.2",
"platformdirs>=4.9.6",
]
[project.scripts]
+9 -6
View File
@@ -10,6 +10,7 @@ from .base import BaseAuthenticator
from .spotify import SpotifyAuthenticator
from .musixmatch import MusixmatchAuthenticator
from .dummy import DummyAuthenticator
from ..config import AppConfig
__all__ = [
"BaseAuthenticator",
@@ -20,11 +21,13 @@ __all__ = [
]
def create_authenticators(cache) -> dict[str, BaseAuthenticator]:
"""Factory function to create authenticators with cache access."""
def create_authenticators(cache, config: AppConfig) -> dict[str, BaseAuthenticator]:
"""Factory function to create authenticators with injected config."""
return {
"dummy": DummyAuthenticator(),
"spotify": SpotifyAuthenticator(cache),
"musixmatch": MusixmatchAuthenticator(cache),
"qqmusic": QQMusicAuthenticator(),
"dummy": DummyAuthenticator(cache, config.credentials, config.general),
"spotify": SpotifyAuthenticator(cache, config.credentials, config.general),
"musixmatch": MusixmatchAuthenticator(
cache, config.credentials, config.general
),
"qqmusic": QQMusicAuthenticator(cache, config.credentials, config.general),
}
+10
View File
@@ -7,10 +7,20 @@ Description: Base class for credential authenticators.
from abc import ABC, abstractmethod
from typing import Optional
from ..cache import CacheEngine
from ..config import CredentialConfig, GeneralConfig
class BaseAuthenticator(ABC):
"""Manages obtaining, caching, and refreshing a credential for one provider."""
def __init__(
self, cache: CacheEngine, credentials: CredentialConfig, general: GeneralConfig
) -> None:
self._cache = cache
self._credentials = credentials
self._general = general
@property
@abstractmethod
def name(self) -> str: ...
+9 -7
View File
@@ -12,7 +12,7 @@ from loguru import logger
from .base import BaseAuthenticator
from ..cache import CacheEngine
from ..config import HTTP_TIMEOUT, MUSIXMATCH_COOLDOWN_MS, credentials
from ..config import CredentialConfig, GeneralConfig, MUSIXMATCH_COOLDOWN_MS
_MUSIXMATCH_TOKEN_URL = "https://apic-desktop.musixmatch.com/ws/1.1/token.get"
@@ -24,8 +24,10 @@ _MXM_BASE_PARAMS = {
class MusixmatchAuthenticator(BaseAuthenticator):
def __init__(self, cache: CacheEngine) -> None:
self._cache = cache
def __init__(
self, cache: CacheEngine, credentials: CredentialConfig, general: GeneralConfig
) -> None:
super().__init__(cache, credentials, general)
self._cached_token: Optional[str] = None
self._cooldown_until_ms: int = 0
@@ -77,7 +79,7 @@ class MusixmatchAuthenticator(BaseAuthenticator):
logger.debug("Musixmatch: fetching anonymous token")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.get(url, headers=_MXM_HEADERS)
resp.raise_for_status()
data = resp.json()
@@ -102,8 +104,8 @@ class MusixmatchAuthenticator(BaseAuthenticator):
async def _get_token(self) -> Optional[str]:
"""Return a valid token: env var > memory > DB > fresh fetch."""
if credentials.MUSIXMATCH_USERTOKEN:
return credentials.MUSIXMATCH_USERTOKEN
if self._credentials.musixmatch_usertoken:
return self._credentials.musixmatch_usertoken
if self._cached_token:
return self._cached_token
@@ -139,7 +141,7 @@ class MusixmatchAuthenticator(BaseAuthenticator):
self._set_cooldown()
return None
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
url = f"{url_base}?{urlencode({**_MXM_BASE_PARAMS, **params, 'usertoken': token})}"
resp = await client.get(url, headers=_MXM_HEADERS)
+8 -5
View File
@@ -7,19 +7,22 @@ Description: QQ Music API authenticator - currently only a proxy.
from typing import Optional
from .base import BaseAuthenticator
from ..config import credentials
from ..cache import CacheEngine
from ..config import CredentialConfig, GeneralConfig
class QQMusicAuthenticator(BaseAuthenticator):
def __init__(self) -> None:
pass
def __init__(
self, cache: CacheEngine, credentials: CredentialConfig, general: GeneralConfig
) -> None:
super().__init__(cache, credentials, general)
@property
def name(self) -> str:
return "qqmusic"
def is_configured(self) -> bool:
return bool(credentials.QQ_MUSIC_API_URL)
return bool(self._credentials.qq_music_api_url)
async def authenticate(self) -> Optional[str]:
return credentials.QQ_MUSIC_API_URL
return self._credentials.qq_music_api_url.rstrip("/") or None
+18 -10
View File
@@ -14,7 +14,7 @@ from loguru import logger
from .base import BaseAuthenticator
from ..cache import CacheEngine
from ..config import HTTP_TIMEOUT, UA_BROWSER, credentials
from ..config import CredentialConfig, GeneralConfig, UA_BROWSER
_SPOTIFY_TOKEN_URL = "https://open.spotify.com/api/token"
_SPOTIFY_SERVER_TIME_URL = "https://open.spotify.com/api/server-time"
@@ -32,8 +32,10 @@ SPOTIFY_BASE_HEADERS = {
class SpotifyAuthenticator(BaseAuthenticator):
def __init__(self, cache: CacheEngine) -> None:
self._cache = cache
def __init__(
self, cache: CacheEngine, credentials: CredentialConfig, general: GeneralConfig
) -> None:
super().__init__(cache, credentials, general)
self._cached_secret: Optional[Tuple[str, int]] = None
self._cached_token: Optional[str] = None
self._token_expires_at: float = 0.0
@@ -43,7 +45,7 @@ class SpotifyAuthenticator(BaseAuthenticator):
return "spotify"
def is_configured(self) -> bool:
return bool(credentials.SPOTIFY_SP_DC)
return bool(self._credentials.spotify_sp_dc)
@staticmethod
def _generate_totp(server_time_s: int, secret: str) -> str:
@@ -82,7 +84,9 @@ class SpotifyAuthenticator(BaseAuthenticator):
async def _get_server_time(self, client: httpx.AsyncClient) -> Optional[int]:
try:
res = await client.get(_SPOTIFY_SERVER_TIME_URL, timeout=HTTP_TIMEOUT)
res = await client.get(
_SPOTIFY_SERVER_TIME_URL, timeout=self._general.http_timeout
)
res.raise_for_status()
data = res.json()
if not isinstance(data, dict) or "serverTime" not in data:
@@ -100,7 +104,9 @@ class SpotifyAuthenticator(BaseAuthenticator):
logger.debug("Spotify: using cached TOTP secret")
return self._cached_secret
try:
res = await client.get(_SPOTIFY_SECRET_URL, timeout=HTTP_TIMEOUT)
res = await client.get(
_SPOTIFY_SECRET_URL, timeout=self._general.http_timeout
)
res.raise_for_status()
data = res.json()
if not isinstance(data, list) or len(data) == 0:
@@ -133,13 +139,13 @@ class SpotifyAuthenticator(BaseAuthenticator):
if db_token and time.time() < self._token_expires_at - 30:
return db_token
if not credentials.SPOTIFY_SP_DC:
logger.error("Spotify: SPOTIFY_SP_DC env var not set — cannot authenticate")
if not self._credentials.spotify_sp_dc:
logger.error("Spotify: spotify_sp_dc not configured — cannot authenticate")
return None
headers = {
"Accept": "*/*",
"Cookie": f"sp_dc={credentials.SPOTIFY_SP_DC}",
"Cookie": f"sp_dc={self._credentials.spotify_sp_dc}",
**SPOTIFY_BASE_HEADERS,
}
@@ -166,7 +172,9 @@ class SpotifyAuthenticator(BaseAuthenticator):
try:
res = await client.get(
_SPOTIFY_TOKEN_URL, params=params, timeout=HTTP_TIMEOUT
_SPOTIFY_TOKEN_URL,
params=params,
timeout=self._general.http_timeout,
)
if res.status_code != 200:
logger.error(f"Spotify: token request returned {res.status_code}")
+39 -32
View File
@@ -17,12 +17,8 @@ from loguru import logger
from .config import (
DB_PATH,
PLAYER_BLACKLIST,
PREFERRED_PLAYER,
WATCH_CALIBRATION_INTERVAL_S,
WATCH_DEBOUNCE_MS,
WATCH_POSITION_TICK_MS,
WATCH_SOCKET_PATH,
AppConfig,
load_config,
enable_debug,
)
from .models import TrackMeta
@@ -32,7 +28,6 @@ from .fetchers import FetcherMethodType
from .lrc import get_sidecar_path
from .watch import WatchCoordinator
from .watch.control import ControlClient, parse_delta
from .watch.options import WatchOptions
from .watch.view.pipe import PipeOutput
@@ -54,23 +49,12 @@ watch_app.command(ctl_app)
# Global state set by the meta launcher
_player: str | None = None
_db_path: str | None = None
_app_config: AppConfig = AppConfig()
# Will be initialized before any command runs, safe to set to None here
manager: LrcManager = None # type: ignore
def _build_watch_options() -> WatchOptions:
"""Build runtime watch options from CLI composition root."""
return WatchOptions(
preferred_player=PREFERRED_PLAYER,
player_blacklist=tuple(PLAYER_BLACKLIST),
debounce_ms=WATCH_DEBOUNCE_MS,
position_tick_ms=WATCH_POSITION_TICK_MS,
calibration_interval_s=WATCH_CALIBRATION_INTERVAL_S,
socket_path=WATCH_SOCKET_PATH,
)
@app.meta.default
def launcher(
*tokens: Annotated[str, cyclopts.Parameter(show=False, allow_leading_hyphen=True)],
@@ -95,13 +79,13 @@ def launcher(
),
] = None,
):
global _player, _db_path
global _player, _db_path, _app_config, manager
if debug:
enable_debug()
_player = player
_db_path = str(Path(db_path).resolve()) if db_path else DB_PATH
global manager
manager = LrcManager(db_path=_db_path)
_app_config = load_config()
manager = LrcManager(db_path=_db_path, config=_app_config)
app(tokens)
@@ -147,7 +131,11 @@ def fetch(
] = False,
):
"""Fetch and print lyrics for the currently playing track."""
track = get_current_track(_player)
track = get_current_track(
_player,
preferred_player=_app_config.general.preferred_player,
player_blacklist=_app_config.general.player_blacklist,
)
if not track:
logger.error("No active playing track found.")
@@ -331,7 +319,11 @@ def export(
] = False,
):
"""Export lyrics of the current track to a .lrc file."""
track = get_current_track(_player)
track = get_current_track(
_player,
preferred_player=_app_config.general.preferred_player,
player_blacklist=_app_config.general.player_blacklist,
)
if not track:
logger.error("No active playing track found.")
sys.exit(1)
@@ -416,13 +408,12 @@ def pipe(
_player or "<none>",
)
output = PipeOutput(before=max(0, before), after=max(0, after))
options = _build_watch_options()
try:
session = WatchCoordinator(
manager,
output,
player_hint=_player,
options=options,
config=_app_config,
)
success = asyncio.run(session.run())
if not success:
@@ -439,7 +430,7 @@ def offset(delta: str) -> None:
logger.error(parse_error or "Invalid offset delta")
sys.exit(1)
response = ControlClient(options=_build_watch_options()).send(
response = ControlClient(config=_app_config).send(
{"cmd": "offset", "delta": parsed_delta}
)
if not response.get("ok"):
@@ -451,7 +442,7 @@ def offset(delta: str) -> None:
@ctl_app.command
def status() -> None:
"""Print current watch session status as JSON."""
response = ControlClient(options=_build_watch_options()).send({"cmd": "status"})
response = ControlClient(config=_app_config).send({"cmd": "status"})
if not response.get("ok"):
logger.error(response.get("error", "Unknown error"))
sys.exit(1)
@@ -480,7 +471,11 @@ def query(
print()
return
track = get_current_track(_player)
track = get_current_track(
_player,
preferred_player=_app_config.general.preferred_player,
player_blacklist=_app_config.general.player_blacklist,
)
if not track:
logger.error("No active playing track found.")
sys.exit(1)
@@ -500,7 +495,11 @@ def clear(
manager.cache.clear_all()
return
track = get_current_track(_player)
track = get_current_track(
_player,
preferred_player=_app_config.general.preferred_player,
player_blacklist=_app_config.general.player_blacklist,
)
if not track:
logger.error("No active playing track found.")
sys.exit(1)
@@ -590,7 +589,11 @@ def confidence(
logger.error("Score must be between 0 and 100.")
sys.exit(1)
track = get_current_track(_player)
track = get_current_track(
_player,
preferred_player=_app_config.general.preferred_player,
player_blacklist=_app_config.general.player_blacklist,
)
if not track:
logger.error("No active playing track found.")
sys.exit(1)
@@ -614,7 +617,11 @@ def insert(
] = None,
):
"""Manually insert lyrics into the cache for the current track."""
track = get_current_track(_player)
track = get_current_track(
_player,
preferred_player=_app_config.general.preferred_player,
player_blacklist=_app_config.general.player_blacklist,
)
if not track:
logger.error("No active playing track found.")
sys.exit(1)
+130 -48
View File
@@ -1,14 +1,18 @@
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 10:17:56
Description: Global configuration constants and logger setup.
Description: Global configuration constants, typed config dataclasses, and logger setup.
"""
import dataclasses
import os
import sys
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, get_type_hints
from platformdirs import user_cache_dir, user_config_dir
from dotenv import load_dotenv
from loguru import logger
from importlib.metadata import version
@@ -24,13 +28,7 @@ DB_PATH = os.path.join(CACHE_DIR, "cache.db")
SLOT_SYNCED = "SYNCED"
SLOT_UNSYNCED = "UNSYNCED"
# .env loading
_config_env = Path(user_config_dir(APP_NAME, APP_AUTHOR)) / ".env"
load_dotenv(_config_env) # ~/.config/lrx-cli/.env
load_dotenv() # .env in cwd (does NOT override existing vars)
# HTTP
HTTP_TIMEOUT = 10.0
_WATCH_SOCKET_PATH = str(Path(CACHE_DIR) / "watch.sock")
# Cache TTLs (seconds)
TTL_SYNCED = None # never expires
@@ -66,47 +64,131 @@ UA_LRX = f"LRX-CLI {APP_VERSION} (https://github.com/Uyanide/lrx-cli)"
MUSIXMATCH_COOLDOWN_MS = 600_000 # 10 minutes
# Player preference (used when multiple MPRIS players are active)
PREFERRED_PLAYER = os.environ.get("PREFERRED_PLAYER", "spotify")
PLAYER_BLACKLIST = [
s.strip() for s in os.environ.get("PLAYER_BLACKLIST", "").split(",") if s.strip()
]
# Watch mode
WATCH_DEBOUNCE_MS = int(os.environ.get("WATCH_DEBOUNCE_MS", "400"))
WATCH_CALIBRATION_INTERVAL_S = float(
os.environ.get("WATCH_CALIBRATION_INTERVAL_S", "3.0")
)
WATCH_POSITION_TICK_MS = int(os.environ.get("WATCH_POSITION_TICK_MS", "50"))
WATCH_SOCKET_PATH = Path(CACHE_DIR) / "watch.sock"
class _Credentials:
"""Credential config with lazy os.environ reads.
Stable constants live as module-level names above.
Credentials are @property so monkeypatch.setenv / monkeypatch.delenv
affect them without needing to patch each consumer separately.
"""
@property
def SPOTIFY_SP_DC(self) -> str:
return os.environ.get("SPOTIFY_SP_DC", "")
@property
def QQ_MUSIC_API_URL(self) -> str:
return os.environ.get("QQ_MUSIC_API_URL", "").rstrip("/")
@property
def MUSIXMATCH_USERTOKEN(self) -> str:
return os.environ.get("MUSIXMATCH_USERTOKEN", "")
credentials = _Credentials()
os.makedirs(CACHE_DIR, exist_ok=True)
# Logger
DEFAULT_PREFERRED_PLAYER = "spotify"
DEFAULT_PLAYER_BLACKLIST: tuple[str, ...] = (
"firefox",
"zen",
"chrome",
"chromium",
"vivaldi",
"edge",
"opera",
"mpv",
)
@dataclass(frozen=True)
class GeneralConfig:
preferred_player: str = DEFAULT_PREFERRED_PLAYER
player_blacklist: tuple[str, ...] = DEFAULT_PLAYER_BLACKLIST
http_timeout: float = 10.0
@dataclass(frozen=True)
class CredentialConfig:
spotify_sp_dc: str = ""
musixmatch_usertoken: str = ""
qq_music_api_url: str = ""
@dataclass(frozen=True)
class WatchConfig:
debounce_ms: int = 400
calibration_interval_s: float = 3.0
position_tick_ms: int = 50
socket_path: str = field(default_factory=lambda: _WATCH_SOCKET_PATH)
@dataclass(frozen=True)
class AppConfig:
general: GeneralConfig = field(default_factory=GeneralConfig)
credentials: CredentialConfig = field(default_factory=CredentialConfig)
watch: WatchConfig = field(default_factory=WatchConfig)
_CONFIG_PATH = Path(user_config_dir(APP_NAME, APP_AUTHOR)) / "config.toml"
def _coerce(val: Any, hint: Any, section: str, name: str) -> Any:
"""Coerce and validate one TOML value against its declared field type."""
if hint is str:
if not isinstance(val, str):
raise ValueError(
f"[{section}].{name}: expected str, got {type(val).__name__}"
)
return val
if hint is int:
if not isinstance(val, int) or isinstance(val, bool):
raise ValueError(
f"[{section}].{name}: expected int, got {type(val).__name__}"
)
return val
if hint is float:
if isinstance(val, bool):
raise ValueError(f"[{section}].{name}: expected float, got bool")
if isinstance(val, (int, float)):
return float(val)
raise ValueError(
f"[{section}].{name}: expected float, got {type(val).__name__}"
)
origin = getattr(hint, "__origin__", None)
if origin is tuple:
if not isinstance(val, list):
raise ValueError(
f"[{section}].{name}: expected array, got {type(val).__name__}"
)
for i, item in enumerate(val):
if not isinstance(item, str):
raise ValueError(
f"[{section}].{name}[{i}]: expected str, got {type(item).__name__}"
)
return tuple(val)
raise ValueError(f"[{section}].{name}: unsupported field type {hint!r}")
def _parse_section(raw: dict[str, Any], cls: type, section: str) -> Any:
"""Parse one TOML section dict into a frozen dataclass, rejecting unknown keys."""
fields_map = {f.name: f for f in dataclasses.fields(cls)}
hints = get_type_hints(cls)
unknown = set(raw) - set(fields_map)
if unknown:
raise ValueError(
f"Unknown config keys in [{section}]: {', '.join(sorted(unknown))}"
)
kwargs: dict[str, Any] = {}
for name, f in fields_map.items():
if name not in raw:
if f.default is not dataclasses.MISSING:
kwargs[name] = f.default
elif f.default_factory is not dataclasses.MISSING: # type: ignore[misc]
kwargs[name] = f.default_factory()
continue
kwargs[name] = _coerce(raw[name], hints[name], section, name)
return cls(**kwargs)
def load_config(path: Path | None = None) -> AppConfig:
"""Load AppConfig from TOML file; return all-defaults when file is absent."""
resolved = path or _CONFIG_PATH
if not resolved.exists():
return AppConfig()
with open(resolved, "rb") as f:
data = tomllib.load(f)
return AppConfig(
general=_parse_section(data.get("general", {}), GeneralConfig, "general"),
credentials=_parse_section(
data.get("credentials", {}), CredentialConfig, "credentials"
),
watch=_parse_section(data.get("watch", {}), WatchConfig, "watch"),
)
_LOG_FORMAT = (
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
+4 -3
View File
@@ -22,6 +22,7 @@ from .config import (
HIGH_CONFIDENCE,
SLOT_SYNCED,
SLOT_UNSYNCED,
AppConfig,
)
from .models import TrackMeta, LyricResult, CacheStatus
from .enrichers import create_enrichers, enrich_track
@@ -92,10 +93,10 @@ def _has_negative_for_both_slots(cached_rows: list[LyricResult]) -> bool:
class LrcManager:
"""Main entry point for fetching lyrics with caching."""
def __init__(self, db_path: str) -> None:
def __init__(self, db_path: str, config: AppConfig = AppConfig()) -> None:
self.cache = CacheEngine(db_path=db_path)
self.authenticators = create_authenticators(self.cache)
self.fetchers = create_fetchers(self.cache, self.authenticators)
self.authenticators = create_authenticators(self.cache, config)
self.fetchers = create_fetchers(self.cache, self.authenticators, config)
self.enrichers = create_enrichers(self.authenticators)
async def _run_group(
+13 -11
View File
@@ -23,6 +23,7 @@ from ..authenticators import (
QQMusicAuthenticator,
)
from ..cache import CacheEngine
from ..config import AppConfig
from ..models import TrackMeta
FetcherMethodType = Literal[
@@ -52,26 +53,27 @@ _FETCHER_GROUPS: list[list[FetcherMethodType]] = [
def create_fetchers(
cache: CacheEngine,
authenticators: dict[str, BaseAuthenticator],
config: AppConfig,
) -> dict[FetcherMethodType, BaseFetcher]:
"""Instantiate all fetchers. Returns a dict keyed by source name."""
spotify_auth = authenticators["spotify"]
mxm_auth = authenticators["musixmatch"]
qqmusic_auth = authenticators.get("qqmusic")
qqmusic_auth = authenticators["qqmusic"]
assert isinstance(spotify_auth, SpotifyAuthenticator)
assert isinstance(mxm_auth, MusixmatchAuthenticator)
assert isinstance(qqmusic_auth, QQMusicAuthenticator)
fetchers: dict[FetcherMethodType, BaseFetcher] = {
"local": LocalFetcher(),
g = config.general
return {
"local": LocalFetcher(g),
"cache-search": CacheSearchFetcher(cache),
"spotify": SpotifyFetcher(spotify_auth),
"lrclib": LrclibFetcher(),
"musixmatch-spotify": MusixmatchSpotifyFetcher(mxm_auth),
"lrclib-search": LrclibSearchFetcher(),
"netease": NeteaseFetcher(),
"qqmusic": QQMusicFetcher(qqmusic_auth),
"musixmatch": MusixmatchFetcher(mxm_auth),
"spotify": SpotifyFetcher(g, spotify_auth),
"lrclib": LrclibFetcher(g),
"musixmatch-spotify": MusixmatchSpotifyFetcher(g, mxm_auth),
"lrclib-search": LrclibSearchFetcher(g),
"netease": NeteaseFetcher(g),
"qqmusic": QQMusicFetcher(g, qqmusic_auth),
"musixmatch": MusixmatchFetcher(g, mxm_auth),
}
return fetchers
def build_plan(
+8
View File
@@ -8,6 +8,8 @@ from abc import ABC, abstractmethod
from typing import Optional
from dataclasses import dataclass
from ..authenticators.base import BaseAuthenticator
from ..config import GeneralConfig
from ..models import CacheStatus, TrackMeta, LyricResult
@@ -38,6 +40,12 @@ class FetchResult:
class BaseFetcher(ABC):
def __init__(
self, general: GeneralConfig, auth: Optional[BaseAuthenticator] = None
) -> None:
self._general = general
self._auth = auth
@property
@abstractmethod
def source_name(self) -> str:
+1 -2
View File
@@ -13,7 +13,6 @@ from .base import BaseFetcher, FetchResult
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
TTL_UNSYNCED,
TTL_NOT_FOUND,
UA_LRX,
@@ -46,7 +45,7 @@ class LrclibFetcher(BaseFetcher):
logger.info(f"LRCLIB: fetching lyrics for {track.display_name()}")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.get(url, headers={"User-Agent": UA_LRX})
if resp.status_code == 404:
+1 -2
View File
@@ -15,7 +15,6 @@ from .selection import SearchCandidate, select_best
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
TTL_UNSYNCED,
TTL_NOT_FOUND,
UA_LRX,
@@ -73,7 +72,7 @@ class LrclibSearchFetcher(BaseFetcher):
had_error = False
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
async def _query(params: dict[str, str]) -> tuple[list[dict], bool]:
url = f"{_LRCLIB_SEARCH_URL}?{urlencode(params)}"
+14 -9
View File
@@ -18,6 +18,7 @@ from loguru import logger
from .base import BaseFetcher, FetchResult
from .selection import SearchCandidate, select_best
from ..authenticators.musixmatch import MusixmatchAuthenticator
from ..config import GeneralConfig
from ..lrc import LRCData
from ..models import CacheStatus, LyricResult, TrackMeta
@@ -145,22 +146,24 @@ async def _fetch_macro(
class MusixmatchSpotifyFetcher(BaseFetcher):
"""Direct lookup by Spotify track ID — no search, single request."""
def __init__(self, auth: MusixmatchAuthenticator) -> None:
self.auth = auth
_auth: MusixmatchAuthenticator
def __init__(self, general: GeneralConfig, auth: MusixmatchAuthenticator) -> None:
super().__init__(general, auth)
@property
def source_name(self) -> str:
return "musixmatch-spotify"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.trackid) and not self.auth.is_cooldown()
return bool(track.trackid) and not self._auth.is_cooldown()
async def fetch(self, track: TrackMeta, bypass_cache: bool = False) -> FetchResult:
logger.info(f"Musixmatch-Spotify: fetching lyrics for {track.display_name()}")
try:
lrc = await _fetch_macro(
self.auth,
self._auth,
{"track_spotify_id": track.trackid}, # type: ignore[dict-item]
)
except AttributeError:
@@ -191,8 +194,10 @@ class MusixmatchSpotifyFetcher(BaseFetcher):
class MusixmatchFetcher(BaseFetcher):
"""Metadata search + best-candidate lyric fetch."""
def __init__(self, auth: MusixmatchAuthenticator) -> None:
self.auth = auth
_auth: MusixmatchAuthenticator
def __init__(self, general: GeneralConfig, auth: MusixmatchAuthenticator) -> None:
super().__init__(general, auth)
@property
def source_name(self) -> str:
@@ -203,7 +208,7 @@ class MusixmatchFetcher(BaseFetcher):
return "musixmatch"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.title) and not self.auth.is_cooldown()
return bool(track.title) and not self._auth.is_cooldown()
async def _search(self, track: TrackMeta) -> tuple[Optional[int], float]:
"""Search for track metadata. Raises on network/HTTP errors."""
@@ -218,7 +223,7 @@ class MusixmatchFetcher(BaseFetcher):
params["q_album"] = track.album
logger.debug(f"Musixmatch: searching for '{track.display_name()}'")
data = await self.auth.get_json(_MUSIXMATCH_SEARCH_URL, params)
data = await self._auth.get_json(_MUSIXMATCH_SEARCH_URL, params)
if data is None:
return None, 0.0
@@ -270,7 +275,7 @@ class MusixmatchFetcher(BaseFetcher):
return FetchResult.from_not_found()
lrc = await _fetch_macro(
self.auth,
self._auth,
{"commontrack_id": str(commontrack_id)},
)
except AttributeError:
+2 -3
View File
@@ -16,7 +16,6 @@ from .selection import SearchCandidate, select_ranked
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
TTL_NOT_FOUND,
MULTI_CANDIDATE_DELAY_S,
UA_BROWSER,
@@ -49,7 +48,7 @@ class NeteaseFetcher(BaseFetcher):
logger.debug(f"Netease: searching for '{query}' (limit={limit})")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.post(
_NETEASE_SEARCH_URL,
headers=_NETEASE_BASE_HEADERS,
@@ -114,7 +113,7 @@ class NeteaseFetcher(BaseFetcher):
logger.debug(f"Netease: fetching lyrics for song_id={song_id}")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.post(
_NETEASE_LYRIC_URL,
headers=_NETEASE_BASE_HEADERS,
+11 -9
View File
@@ -18,7 +18,7 @@ from .selection import SearchCandidate, select_ranked
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
GeneralConfig,
TTL_NOT_FOUND,
MULTI_CANDIDATE_DELAY_S,
)
@@ -29,15 +29,17 @@ from ..authenticators import QQMusicAuthenticator
class QQMusicFetcher(BaseFetcher):
def __init__(self, auth: QQMusicAuthenticator) -> None:
self.auth = auth
_auth: QQMusicAuthenticator
def __init__(self, general: GeneralConfig, auth: QQMusicAuthenticator) -> None:
super().__init__(general, auth)
@property
def source_name(self) -> str:
return "qqmusic"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.title) and self.auth.is_configured()
return bool(track.title) and self._auth.is_configured()
async def _search(
self, track: TrackMeta, limit: int = 10
@@ -49,9 +51,9 @@ class QQMusicFetcher(BaseFetcher):
logger.debug(f"QQMusic: searching for '{query}' (limit={limit})")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.get(
f"{await self.auth.authenticate()}{_QQ_MUSIC_API_SEARCH_ENDPOINT}",
f"{await self._auth.authenticate()}{_QQ_MUSIC_API_SEARCH_ENDPOINT}",
params={"keyword": query, "type": "song", "num": limit},
)
resp.raise_for_status()
@@ -106,9 +108,9 @@ class QQMusicFetcher(BaseFetcher):
logger.debug(f"QQMusic: fetching lyrics for mid={mid}")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.get(
f"{await self.auth.authenticate()}{_QQ_MUSIC_API_LYRIC_ENDPOINT}",
f"{await self._auth.authenticate()}{_QQ_MUSIC_API_LYRIC_ENDPOINT}",
params={"mid": mid},
)
resp.raise_for_status()
@@ -154,7 +156,7 @@ class QQMusicFetcher(BaseFetcher):
return FetchResult.from_network_error()
async def fetch(self, track: TrackMeta, bypass_cache: bool = False) -> FetchResult:
if not self.auth.is_configured():
if not self._auth.is_configured():
logger.debug("QQMusic: skipped — Auth not configured")
return FetchResult()
+8 -6
View File
@@ -11,21 +11,23 @@ from .base import BaseFetcher, FetchResult
from ..authenticators.spotify import SpotifyAuthenticator, SPOTIFY_BASE_HEADERS
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import HTTP_TIMEOUT, TTL_NOT_FOUND
from ..config import GeneralConfig, TTL_NOT_FOUND
_SPOTIFY_LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/"
class SpotifyFetcher(BaseFetcher):
def __init__(self, auth: SpotifyAuthenticator) -> None:
self.auth = auth
def __init__(self, general: GeneralConfig, auth: SpotifyAuthenticator) -> None:
super().__init__(general, auth)
_auth: SpotifyAuthenticator
@property
def source_name(self) -> str:
return "spotify"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.trackid) and self.auth.is_configured()
return bool(track.trackid) and self._auth.is_configured()
@staticmethod
def _format_lrc_line(start_ms: int, words: str) -> str:
@@ -52,7 +54,7 @@ class SpotifyFetcher(BaseFetcher):
logger.info(f"Spotify: fetching lyrics for trackid={track.trackid}")
token = await self.auth.authenticate()
token = await self._auth.authenticate()
if not token:
logger.error("Spotify: cannot fetch lyrics without a token")
return FetchResult.from_network_error()
@@ -65,7 +67,7 @@ class SpotifyFetcher(BaseFetcher):
}
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
res = await client.get(url, headers=headers)
if res.status_code == 404:
+32 -13
View File
@@ -8,14 +8,17 @@ import asyncio
from dbus_next.aio.message_bus import MessageBus
from dbus_next.constants import BusType
from dbus_next.message import Message
from lrx_cli.config import DEFAULT_PLAYER_BLACKLIST, DEFAULT_PREFERRED_PLAYER
from lrx_cli.models import TrackMeta
from lrx_cli.config import PREFERRED_PLAYER
from loguru import logger
from typing import Optional, List, Any
async def _list_mpris_players(bus: MessageBus) -> List[str]:
"""List all MPRIS player bus names."""
async def _list_mpris_players(
bus: MessageBus,
player_blacklist: tuple[str, ...],
) -> List[str]:
"""List all MPRIS player bus names, excluding blacklisted entries."""
try:
reply = await bus.call(
Message(
@@ -28,7 +31,10 @@ async def _list_mpris_players(bus: MessageBus) -> List[str]:
if not reply or not reply.body:
return []
return [
name for name in reply.body[0] if name.startswith("org.mpris.MediaPlayer2.")
name
for name in reply.body[0]
if name.startswith("org.mpris.MediaPlayer2.")
and not any(x.lower() in name.lower() for x in player_blacklist)
]
except Exception as e:
logger.error(f"Failed to list DBus names: {e}")
@@ -53,15 +59,18 @@ async def _get_playback_status(bus: MessageBus, player_name: str) -> Optional[st
async def _select_player(
bus: MessageBus, specific_player: Optional[str] = None
bus: MessageBus,
specific_player: Optional[str],
preferred_player: str,
player_blacklist: tuple[str, ...],
) -> Optional[str]:
"""Select the best MPRIS player.
When specific_player is given, filter by name match.
Otherwise: prefer the currently playing player. If multiple are playing,
prefer the one matching PREFERRED_PLAYER env var (default: spotify).
prefer the one matching preferred_player (default: spotify).
"""
players = await _list_mpris_players(bus)
players = await _list_mpris_players(bus, player_blacklist)
if not players:
return None
@@ -82,8 +91,8 @@ async def _select_player(
if len(candidates) == 1:
return candidates[0]
# Multiple candidates: prefer PREFERRED_PLAYER
preferred = PREFERRED_PLAYER.lower()
# Multiple candidates: prefer preferred_player
preferred = preferred_player.lower()
if preferred:
for p in candidates:
if preferred in p.lower():
@@ -92,7 +101,9 @@ async def _select_player(
async def _fetch_metadata_dbus(
specific_player: Optional[str] = None,
specific_player: Optional[str],
preferred_player: str,
player_blacklist: tuple[str, ...],
) -> Optional[TrackMeta]:
bus = None
try:
@@ -102,7 +113,9 @@ async def _fetch_metadata_dbus(
return None
try:
player_name = await _select_player(bus, specific_player)
player_name = await _select_player(
bus, specific_player, preferred_player, player_blacklist
)
if not player_name:
logger.debug(
f"No active MPRIS players found via DBus{' for ' + specific_player if specific_player else ''}."
@@ -182,9 +195,15 @@ async def _fetch_metadata_dbus(
bus.disconnect()
def get_current_track(player_name: Optional[str] = None) -> Optional[TrackMeta]:
def get_current_track(
player_name: Optional[str] = None,
preferred_player: str = DEFAULT_PREFERRED_PLAYER,
player_blacklist: tuple[str, ...] = DEFAULT_PLAYER_BLACKLIST,
) -> Optional[TrackMeta]:
try:
return asyncio.run(_fetch_metadata_dbus(player_name))
return asyncio.run(
_fetch_metadata_dbus(player_name, preferred_player, player_blacklist)
)
except Exception as e:
logger.error(f"DBus async loop failed: {e}")
return None
+15 -35
View File
@@ -3,49 +3,32 @@
import asyncio
import json
from pathlib import Path
from typing import Protocol, TypeAlias
from typing import TYPE_CHECKING
from loguru import logger
from .options import WatchOptions
from ..config import AppConfig
JSONPrimitive: TypeAlias = str | int | float | bool | None
JSONValue: TypeAlias = JSONPrimitive | dict[str, "JSONValue"] | list["JSONValue"]
JSONDict: TypeAlias = dict[str, JSONValue]
class ControlSession(Protocol):
"""Session protocol used by control channel handlers."""
def handle_offset(self, delta: int) -> JSONDict:
"""Apply offset delta and return JSON response payload."""
...
def handle_status(self) -> JSONDict:
"""Return current session status payload."""
...
if TYPE_CHECKING:
from .session import WatchCoordinator
class ControlServer:
"""Control server that handles offset/status commands over a Unix socket."""
_options: WatchOptions
_socket_path: Path
_server: asyncio.AbstractServer | None
def __init__(
self,
options: WatchOptions,
config: AppConfig,
socket_path: Path | None = None,
) -> None:
"""Initialize control server with explicit socket path or runtime options."""
self._options = options
resolved_socket_path = socket_path or self._options.socket_path
self._socket_path: Path = resolved_socket_path
"""Initialize control server with socket path from config or explicit override."""
self._socket_path: Path = socket_path or Path(config.watch.socket_path)
self._server: asyncio.AbstractServer | None = None
async def start(self, session: ControlSession) -> bool:
async def start(self, session: "WatchCoordinator") -> bool:
"""Start listening for control requests and bind session handlers."""
if not await self._prepare_socket_path():
return False
@@ -90,12 +73,12 @@ class ControlServer:
async def _handle(
self,
session: ControlSession,
session: "WatchCoordinator",
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
"""Handle one control request and send JSON response."""
resp: JSONDict = {"ok": False, "error": "internal error"}
resp: dict[str, object] = {"ok": False, "error": "internal error"}
try:
line = await reader.readline()
if not line:
@@ -122,20 +105,17 @@ class ControlServer:
class ControlClient:
"""Control client used by CLI commands to talk to active watch session."""
_options: WatchOptions
_socket_path: Path
def __init__(
self,
options: WatchOptions,
config: AppConfig,
socket_path: Path | None = None,
) -> None:
"""Initialize control client with explicit socket path or runtime options."""
self._options = options
resolved_socket_path = socket_path or self._options.socket_path
self._socket_path: Path = resolved_socket_path
"""Initialize control client with socket path from config or explicit override."""
self._socket_path: Path = socket_path or Path(config.watch.socket_path)
async def _send_async(self, cmd: JSONDict) -> JSONDict:
async def _send_async(self, cmd: dict[str, object]) -> dict[str, object]:
"""Send one JSON command to control server and return JSON response."""
if not self._socket_path.exists():
return {"ok": False, "error": "No watch session running."}
@@ -154,7 +134,7 @@ class ControlClient:
return {"ok": False, "error": "Empty response."}
return json.loads(line.decode("utf-8"))
def send(self, cmd: JSONDict) -> JSONDict:
def send(self, cmd: dict[str, object]) -> dict[str, object]:
"""Synchronous wrapper around async control request."""
return asyncio.run(self._send_async(cmd))
+5 -5
View File
@@ -3,15 +3,15 @@
import asyncio
from typing import Awaitable, Callable, Optional
from ..config import AppConfig
from ..lrc import LRCData
from ..models import TrackMeta
from .options import WatchOptions
class LyricFetcher:
"""Debounces track updates and runs at most one lyric fetch task at a time."""
_options: WatchOptions
_config: AppConfig
_fetch_func: Callable[[TrackMeta], Awaitable[Optional[LRCData]]]
_on_fetching: Callable[[], Awaitable[None] | None]
_on_result: Callable[[Optional[LRCData]], Awaitable[None] | None]
@@ -24,10 +24,10 @@ class LyricFetcher:
fetch_func: Callable[[TrackMeta], Awaitable[Optional[LRCData]]],
on_fetching: Callable[[], Awaitable[None] | None],
on_result: Callable[[Optional[LRCData]], Awaitable[None] | None],
options: WatchOptions,
config: AppConfig,
) -> None:
"""Initialize fetch callbacks and runtime options."""
self._options = options
self._config = config
self._fetch_func = fetch_func
self._on_fetching = on_fetching
self._on_result = on_result
@@ -56,7 +56,7 @@ class LyricFetcher:
async def _debounce_then_fetch(self) -> None:
"""Wait debounce window then start a fresh fetch task for latest pending track."""
await asyncio.sleep(self._options.debounce_ms / 1000.0)
await asyncio.sleep(self._config.watch.debounce_ms / 1000.0)
track = self._pending_track
if track is None:
return
-16
View File
@@ -1,16 +0,0 @@
"""Watch runtime options passed from CLI composition root."""
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class WatchOptions:
"""Runtime settings used by watch components."""
preferred_player: str
player_blacklist: tuple[str, ...]
debounce_ms: int
position_tick_ms: int
calibration_interval_s: float
socket_path: Path
+9 -8
View File
@@ -9,8 +9,8 @@ from dbus_next.constants import BusType
from dbus_next.message import Message
from loguru import logger
from ..config import AppConfig
from ..models import TrackMeta
from .options import WatchOptions
def _variant_value(item: object) -> object | None:
@@ -75,7 +75,7 @@ def _keyword_match(text: str, keyword: str) -> bool:
class PlayerMonitor:
"""Tracks MPRIS players and forwards signal-driven state updates to session callbacks."""
_options: WatchOptions
_config: AppConfig
_on_players_changed: Callable[[], None]
_on_seeked: Callable[[str, int], None]
_on_playback_status: Callable[[str, str], None]
@@ -89,16 +89,16 @@ class PlayerMonitor:
on_players_changed: Callable[[], None],
on_seeked: Callable[[str, int], None],
on_playback_status: Callable[[str, str], None],
options: WatchOptions,
config: AppConfig,
target: Optional[PlayerTarget] = None,
) -> None:
"""Initialize monitor callbacks, runtime options, and player target filter."""
self._options = options
self._config = config
self._on_players_changed = on_players_changed
self._on_seeked = on_seeked
self._on_playback_status = on_playback_status
self._target = target or PlayerTarget(
player_blacklist=self._options.player_blacklist
player_blacklist=self._config.general.player_blacklist
)
self.players: dict[str, PlayerState] = {}
self._bus: MessageBus | None = None
@@ -184,7 +184,8 @@ class PlayerMonitor:
if not name.startswith("org.mpris.MediaPlayer2."):
continue
if any(
x.lower() in name.lower() for x in self._options.player_blacklist
x.lower() in name.lower()
for x in self._config.general.player_blacklist
):
continue
if not self._target.allows(name):
@@ -388,7 +389,7 @@ class ActivePlayerSelector:
def select(
players: dict[str, PlayerState],
last_active: str | None,
options: WatchOptions,
config: AppConfig,
) -> str | None:
"""Select active player by playing state, preferred keyword, and continuity."""
if not players:
@@ -398,7 +399,7 @@ class ActivePlayerSelector:
if len(playing) == 1:
return playing[0]
preferred = options.preferred_player.lower().strip()
preferred = config.general.preferred_player.lower().strip()
candidates = playing if playing else list(players.keys())
if preferred:
for name in candidates:
+20 -34
View File
@@ -7,35 +7,21 @@
import asyncio
from dataclasses import asdict
from typing import Optional, Protocol
from typing import Optional
from loguru import logger
from ..fetchers import FetcherMethodType
from ..core import LrcManager
from ..lrc import LRCData
from ..models import LyricResult
from ..models import TrackMeta
from .control import ControlServer
from .fetcher import LyricFetcher
from .options import WatchOptions
from ..config import AppConfig
from .view import BaseOutput, LyricView, WatchState
from .player import ActivePlayerSelector, PlayerMonitor, PlayerTarget
from .tracker import PositionTracker
class FetchManager(Protocol):
"""Protocol for lyric fetch manager consumed by watch session."""
def fetch_for_track(
self,
track: TrackMeta,
force_method: FetcherMethodType | None = None,
bypass_cache: bool = False,
allow_unsynced: bool = False,
) -> Optional[LyricResult]:
"""Fetch lyrics for one track."""
class WatchModel:
"""Model layer that owns watch state and lyric timeline representation."""
@@ -103,9 +89,9 @@ class WatchViewModel:
class WatchCoordinator:
"""Application/service orchestration layer for watch runtime."""
_manager: FetchManager
_manager: LrcManager
_output: BaseOutput
_options: WatchOptions
_config: AppConfig
_model: WatchModel
_view_model: WatchViewModel
_player_hint: str | None
@@ -120,14 +106,14 @@ class WatchCoordinator:
def __init__(
self,
manager: FetchManager,
manager: LrcManager,
output: BaseOutput,
player_hint: str | None,
options: WatchOptions,
config: AppConfig,
) -> None:
self._manager = manager
self._output = output
self._options = options
self._config = config
self._model = WatchModel()
self._view_model = WatchViewModel(self._model)
self._player_hint = player_hint
@@ -137,27 +123,27 @@ class WatchCoordinator:
self._target = PlayerTarget(
hint=player_hint,
player_blacklist=self._options.player_blacklist,
player_blacklist=self._config.general.player_blacklist,
)
self._control = ControlServer(options=self._options)
self._control = ControlServer(config=self._config)
self._player_monitor = PlayerMonitor(
on_players_changed=self._on_player_change,
on_seeked=self._on_seeked,
on_playback_status=self._on_playback_status,
options=self._options,
config=self._config,
target=self._target,
)
self._tracker = PositionTracker(
poll_position_ms=self._player_monitor.get_position_ms,
options=self._options,
config=self._config,
on_tick=self._on_tracker_tick,
)
self._fetcher = LyricFetcher(
fetch_func=self._fetch_lyrics,
on_fetching=self._on_fetching,
on_result=self._on_lyrics_update,
options=self._options,
config=self._config,
)
async def run(self) -> bool:
@@ -199,7 +185,7 @@ class WatchCoordinator:
async def _calibration_loop(self) -> None:
"""Periodically refresh full MPRIS snapshot as fallback calibration."""
interval = max(0.1, self._options.calibration_interval_s)
interval = max(0.1, self._config.watch.calibration_interval_s)
while True:
await asyncio.sleep(interval)
try:
@@ -234,7 +220,7 @@ class WatchCoordinator:
track,
None,
False,
True,
False,
)
if result and result.lyrics:
return result.lyrics
@@ -248,7 +234,7 @@ class WatchCoordinator:
selected = ActivePlayerSelector.select(
self._player_monitor.players,
self._model.active_player,
self._options,
self._config,
)
self._model.active_player = selected
@@ -313,8 +299,8 @@ class WatchCoordinator:
self._model.status = "ok"
elif started_fetch:
self._model.status = "fetching"
else:
self._model.status = "paused"
elif self._model.status != "fetching":
self._model.status = "no_lyrics"
self._schedule_emit()
def _on_seeked(self, bus_name: str, position_ms: int) -> None:
@@ -330,8 +316,8 @@ class WatchCoordinator:
self._model.status = "ok"
elif started_fetch:
self._model.status = "fetching"
else:
self._model.status = "paused"
elif self._model.status != "fetching":
self._model.status = "no_lyrics"
else:
self._model.status = "paused"
self._schedule_emit()
+5 -5
View File
@@ -4,13 +4,13 @@ import asyncio
import time
from typing import Awaitable, Callable, Optional
from .options import WatchOptions
from ..config import AppConfig
class PositionTracker:
"""Maintains an estimated playback position from seek/status events plus local clock."""
_options: WatchOptions
_config: AppConfig
_poll_position_ms: Callable[[str], Awaitable[Optional[int]]]
_active_player: str | None
_is_playing: bool
@@ -24,11 +24,11 @@ class PositionTracker:
def __init__(
self,
poll_position_ms: Callable[[str], Awaitable[Optional[int]]],
options: WatchOptions,
config: AppConfig,
on_tick: Callable[[], None] | None = None,
) -> None:
"""Initialize tracker with position polling callback and runtime options."""
self._options = options
self._config = config
self._poll_position_ms = poll_position_ms
self._on_tick = on_tick
self._active_player: str | None = None
@@ -105,7 +105,7 @@ class PositionTracker:
async def _fast_loop(self) -> None:
"""Advance position by monotonic clock while active player is playing."""
interval = self._options.position_tick_ms / 1000.0
interval = self._config.watch.position_tick_ms / 1000.0
while True:
await asyncio.sleep(interval)
should_notify = False
View File
-10
View File
@@ -1,13 +1,3 @@
import pytest
from lrx_cli.config import enable_debug
enable_debug()
@pytest.fixture
def no_credentials(monkeypatch):
"""Clear all credential env vars so only anonymous fetchers are active."""
monkeypatch.delenv("SPOTIFY_SP_DC", raising=False)
monkeypatch.delenv("QQ_MUSIC_API_URL", raising=False)
monkeypatch.delenv("MUSIXMATCH_USERTOKEN", raising=False)
+10 -8
View File
@@ -1,16 +1,18 @@
import os
import pytest
from lrx_cli.config import load_config
_credentials = load_config().credentials
requires_spotify = pytest.mark.skipif(
not os.environ.get("SPOTIFY_SP_DC"),
reason="requires SPOTIFY_SP_DC",
not _credentials.spotify_sp_dc,
reason="requires credentials.spotify_sp_dc in config.toml",
)
requires_qq_music = pytest.mark.skipif(
not os.environ.get("QQ_MUSIC_API_URL"),
reason="requires QQ_MUSIC_API_URL",
not _credentials.qq_music_api_url,
reason="requires credentials.qq_music_api_url in config.toml",
)
requires_musixmatch_token = pytest.mark.skipif(
not os.environ.get("MUSIXMATCH_USERTOKEN"),
reason="requires MUSIXMATCH_USERTOKEN",
not _credentials.musixmatch_usertoken,
reason="requires credentials.musixmatch_usertoken in config.toml",
)
+59
View File
@@ -0,0 +1,59 @@
import pytest
from lrx_cli.config import AppConfig, CredentialConfig, WatchConfig, load_config
def test_missing_file_returns_defaults(tmp_path):
assert load_config(tmp_path / "nonexistent.toml") == AppConfig()
def test_empty_file_returns_defaults(tmp_path):
p = tmp_path / "config.toml"
p.write_text("")
assert load_config(p) == AppConfig()
def test_partial_section_keeps_other_defaults(tmp_path):
p = tmp_path / "config.toml"
p.write_bytes(b"[watch]\ndebounce_ms = 200\n")
cfg = load_config(p)
assert cfg.watch.debounce_ms == 200
assert cfg.watch.calibration_interval_s == WatchConfig().calibration_interval_s
def test_credentials_roundtrip(tmp_path):
p = tmp_path / "config.toml"
p.write_bytes(
b"[credentials]\n"
b'spotify_sp_dc = "abc"\n'
b'qq_music_api_url = "http://localhost:3000"\n'
)
assert load_config(p).credentials == CredentialConfig(
spotify_sp_dc="abc", qq_music_api_url="http://localhost:3000"
)
def test_int_coerced_to_float(tmp_path):
p = tmp_path / "config.toml"
p.write_bytes(b"[general]\nhttp_timeout = 5\n")
assert load_config(p).general.http_timeout == 5.0
def test_unknown_key_raises(tmp_path):
p = tmp_path / "config.toml"
p.write_bytes(b"[general]\ntypo_key = 1\n")
with pytest.raises(ValueError, match="Unknown config keys"):
load_config(p)
def test_wrong_type_raises(tmp_path):
p = tmp_path / "config.toml"
p.write_bytes(b"[watch]\ndebounce_ms = true\n")
with pytest.raises(ValueError, match="expected int"):
load_config(p)
def test_app_config_is_frozen():
cfg = AppConfig()
with pytest.raises(Exception):
cfg.general = None # type: ignore[misc]
+23 -15
View File
@@ -1,14 +1,16 @@
from pathlib import Path
import pytest
from dataclasses import replace
from pathlib import Path
import pytest
from lrx_cli.config import AppConfig, load_config
from lrx_cli.core import LrcManager
from lrx_cli.fetchers import FetcherMethodType
from lrx_cli.models import TrackMeta
from lrx_cli.core import LrcManager
from tests.marks import (
requires_spotify,
requires_qq_music,
requires_musixmatch_token,
requires_qq_music,
requires_spotify,
)
SAMPLE_SPOTIFY_TRACK: TrackMeta = TrackMeta(
@@ -33,7 +35,14 @@ SAMPLE_SPOTIFY_TRACK_ALBUM_ARTIST_MODIFIED = replace(
@pytest.fixture
def lrc_manager(tmp_path: Path) -> LrcManager:
return LrcManager(str(tmp_path / "cache.db"))
"""LrcManager with empty credentials (no auth required)."""
return LrcManager(str(tmp_path / "cache.db"), AppConfig())
@pytest.fixture
def cred_lrc_manager(tmp_path: Path) -> LrcManager:
"""LrcManager with credentials from config.toml (for CI/network tests)."""
return LrcManager(str(tmp_path / "cache.db"), load_config())
def _fetch_and_assert(
@@ -112,7 +121,6 @@ def test_cache_search_fetcher_prefer_better_match(lrc_manager: LrcManager):
],
)
def test_anonymous_remote_fetchers(
no_credentials,
lrc_manager: LrcManager,
method: FetcherMethodType,
expect_fail: bool,
@@ -122,18 +130,18 @@ def test_anonymous_remote_fetchers(
@pytest.mark.network
@requires_spotify
def test_spotify_fetcher(lrc_manager: LrcManager):
_fetch_and_assert(lrc_manager, "spotify")
def test_spotify_fetcher(cred_lrc_manager: LrcManager):
_fetch_and_assert(cred_lrc_manager, "spotify")
@pytest.mark.network
@requires_qq_music
def test_qqmusic_fetcher(lrc_manager: LrcManager):
_fetch_and_assert(lrc_manager, "qqmusic")
def test_qqmusic_fetcher(cred_lrc_manager: LrcManager):
_fetch_and_assert(cred_lrc_manager, "qqmusic")
@pytest.mark.network
def test_musixmatch_anonymous_fetcher(no_credentials, lrc_manager: LrcManager):
def test_musixmatch_anonymous_fetcher(lrc_manager: LrcManager):
# These fetchers should be tested in a single test to share the same usertoken
# Otherwise the second may fail due to rate limits
_fetch_and_assert(lrc_manager, "musixmatch", expect_fail=False)
@@ -142,9 +150,9 @@ def test_musixmatch_anonymous_fetcher(no_credentials, lrc_manager: LrcManager):
@pytest.mark.network
@requires_musixmatch_token
def test_musixmatch_fetcher(lrc_manager: LrcManager):
_fetch_and_assert(lrc_manager, "musixmatch")
_fetch_and_assert(lrc_manager, "musixmatch-spotify")
def test_musixmatch_fetcher(cred_lrc_manager: LrcManager):
_fetch_and_assert(cred_lrc_manager, "musixmatch")
_fetch_and_assert(cred_lrc_manager, "musixmatch-spotify")
def test_local_fetcher(lrc_manager: LrcManager):
+123
View File
@@ -0,0 +1,123 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from lrx_cli.config import AppConfig
from lrx_cli.enrichers.audio_tag import AudioTagEnricher
from lrx_cli.enrichers.file_name import FileNameEnricher
from lrx_cli.models import CacheStatus, TrackMeta
from lrx_cli.fetchers.local import LocalFetcher
_GENERAL = AppConfig().general
def _local_track(path: Path) -> TrackMeta:
return TrackMeta(url=f"file://{path}")
def test_local_fetcher_unavailable_for_non_local_track():
fetcher = LocalFetcher(_GENERAL)
assert not fetcher.is_available(TrackMeta(title="Song", artist="Artist"))
def test_local_fetcher_available_for_local_track(tmp_path):
fetcher = LocalFetcher(_GENERAL)
assert fetcher.is_available(_local_track(tmp_path / "song.flac"))
def test_local_fetcher_returns_empty_for_non_file_url():
fetcher = LocalFetcher(_GENERAL)
track = TrackMeta(url="https://example.com/song.mp3")
result = asyncio.run(fetcher.fetch(track))
assert result.synced is None and result.unsynced is None
def test_local_fetcher_reads_synced_sidecar(tmp_path):
audio = tmp_path / "song.flac"
lrc = audio.with_suffix(".lrc")
lrc.write_text("[00:01.00]Hello\n[00:03.00]World\n")
fetcher = LocalFetcher(_GENERAL)
result = asyncio.run(fetcher.fetch(_local_track(audio)))
assert result.synced is not None
assert result.synced.status == CacheStatus.SUCCESS_SYNCED
assert result.synced.source is not None
assert "sidecar" in result.synced.source
def test_local_fetcher_reads_unsynced_sidecar(tmp_path):
audio = tmp_path / "song.flac"
lrc = audio.with_suffix(".lrc")
lrc.write_text("Hello\nWorld\n")
fetcher = LocalFetcher(_GENERAL)
result = asyncio.run(fetcher.fetch(_local_track(audio)))
assert result.unsynced is not None
assert result.synced is None
def test_local_fetcher_empty_sidecar_ignored(tmp_path):
audio = tmp_path / "song.flac"
(audio.with_suffix(".lrc")).write_text(" ")
fetcher = LocalFetcher(_GENERAL)
result = asyncio.run(fetcher.fetch(_local_track(audio)))
assert result.synced is None and result.unsynced is None
def _enrich(path: str, **existing) -> dict | None:
enricher = FileNameEnricher()
track = TrackMeta(url=f"file://{path}", **existing)
return asyncio.run(enricher.enrich(track))
def test_filename_enricher_artist_title_split(tmp_path):
result = _enrich(str(tmp_path / "Utada Hikaru - First Love.flac"))
assert result == {
"artist": "Utada Hikaru",
"title": "First Love",
"album": tmp_path.name,
}
def test_filename_enricher_track_number_prefix(tmp_path):
# "01. Title" — no " - " separator, regex strips leading "01. "
result = _enrich(str(tmp_path / "01. First Love.flac"))
assert result and result.get("title") == "First Love"
assert "artist" not in result
def test_filename_enricher_title_only(tmp_path):
result = _enrich(str(tmp_path / "First Love.flac"))
assert result and result.get("title") == "First Love"
def test_filename_enricher_does_not_overwrite_existing_fields(tmp_path):
result = _enrich(
str(tmp_path / "Artist - Title.flac"),
artist="Existing Artist",
title="Existing Title",
)
assert result is None or ("artist" not in result and "title" not in result)
def test_filename_enricher_non_local_returns_none():
enricher = FileNameEnricher()
track = TrackMeta(title="Song", artist="Artist")
assert asyncio.run(enricher.enrich(track)) is None
def test_audio_tag_enricher_non_local_returns_none():
enricher = AudioTagEnricher()
track = TrackMeta(title="Song", artist="Artist")
assert asyncio.run(enricher.enrich(track)) is None
def test_audio_tag_enricher_missing_file_returns_none(tmp_path):
enricher = AudioTagEnricher()
track = _local_track(tmp_path / "nonexistent.flac")
assert asyncio.run(enricher.enrich(track)) is None
+50
View File
@@ -277,3 +277,53 @@ def test_unsynced_cache_only_still_fetches_when_unsynced_disallowed(tmp_path):
assert fetcher.called
assert result is not None
assert result.status == CacheStatus.SUCCESS_SYNCED
# manual_insert
def test_manual_insert_synced_stored_with_correct_status(tmp_path):
manager = make_manager(tmp_path)
manager.manual_insert(_track(), "[00:01.00]Hello\n[00:03.00]World\n")
rows = manager.cache.query_track(_track())
assert any(r["status"] == CacheStatus.SUCCESS_SYNCED.value for r in rows)
def test_manual_insert_unsynced_stored_with_correct_status(tmp_path):
manager = make_manager(tmp_path)
manager.manual_insert(_track(), "Hello\nWorld\n")
rows = manager.cache.query_track(_track())
assert any(r["status"] == CacheStatus.SUCCESS_UNSYNCED.value for r in rows)
def test_manual_insert_source_and_ttl(tmp_path):
manager = make_manager(tmp_path)
manager.manual_insert(_track(), "[00:01.00]line\n")
rows = manager.cache.query_track(_track())
assert all(r["source"] == "manual" for r in rows)
assert all(r["expires_at"] is None for r in rows)
def test_manual_insert_overwrites_previous_entry(tmp_path):
manager = make_manager(tmp_path)
track = _track()
manager.manual_insert(track, "[00:01.00]old\n")
manager.manual_insert(track, "[00:01.00]new\n")
best = manager.cache.get_best(track, ["manual"])
assert best is not None
assert str(best.lyrics) == "[00:01.00]new"
def test_manual_insert_is_returned_by_fetch(tmp_path):
manager = make_manager(tmp_path)
track = _track()
manager.manual_insert(track, "[00:01.00]cached\n")
result = manager.fetch_for_track(track)
assert result is not None
assert result.lyrics is not None
assert str(result.lyrics) == "[00:01.00]cached"
+21 -28
View File
@@ -10,19 +10,12 @@ from lrx_cli.watch.view import BaseOutput, LyricView, WatchState
from lrx_cli.watch.view.pipe import PipeOutput
from lrx_cli.watch.player import ActivePlayerSelector, PlayerState, PlayerTarget
from lrx_cli.watch.fetcher import LyricFetcher
from lrx_cli.watch.options import WatchOptions
from lrx_cli.config import AppConfig
from lrx_cli.watch.tracker import PositionTracker
from lrx_cli.watch.session import WatchCoordinator
TEST_WATCH_OPTIONS = WatchOptions(
preferred_player="spotify",
player_blacklist=(),
debounce_ms=400,
position_tick_ms=50,
calibration_interval_s=3.0,
socket_path=Path("/tmp/lrx-watch-test.sock"),
)
TEST_CONFIG = AppConfig()
def test_parse_delta_supports_plus_minus_and_reset() -> None:
@@ -64,7 +57,7 @@ def test_active_player_selector_prefers_single_playing() -> None:
),
}
assert (
ActivePlayerSelector.select(players, None, TEST_WATCH_OPTIONS)
ActivePlayerSelector.select(players, None, TEST_CONFIG)
== "org.mpris.MediaPlayer2.bar"
)
@@ -87,7 +80,7 @@ def test_active_player_selector_uses_last_active_when_no_playing() -> None:
ActivePlayerSelector.select(
players,
"org.mpris.MediaPlayer2.bar",
TEST_WATCH_OPTIONS,
TEST_CONFIG,
)
== "org.mpris.MediaPlayer2.bar"
)
@@ -98,7 +91,7 @@ def test_position_tracker_seeked_calibrates_immediately() -> None:
async def _poll(_bus: str):
return 1200
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS)
tracker = PositionTracker(_poll, TEST_CONFIG)
await tracker.start()
await tracker.set_active_player(
"org.mpris.MediaPlayer2.foo", "Playing", "track-A"
@@ -116,7 +109,7 @@ def test_position_tracker_playback_status_pause_stops_fast_growth() -> None:
async def _poll(_bus: str):
return 0
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS)
tracker = PositionTracker(_poll, TEST_CONFIG)
await tracker.start()
await tracker.set_active_player(
"org.mpris.MediaPlayer2.foo", "Playing", "track-A"
@@ -140,7 +133,7 @@ def test_position_tracker_playback_status_playing_calibrates_once() -> None:
async def _poll(_bus: str):
return 50000
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS)
tracker = PositionTracker(_poll, TEST_CONFIG)
await tracker.start()
await tracker.set_active_player(
"org.mpris.MediaPlayer2.foo", "Paused", "track-A"
@@ -159,7 +152,7 @@ def test_position_tracker_set_active_player_playing_calibrates_on_resume() -> No
async def _poll(_bus: str):
return 42000
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS)
tracker = PositionTracker(_poll, TEST_CONFIG)
await tracker.start()
await tracker.set_active_player(
"org.mpris.MediaPlayer2.foo", "Paused", "track-A"
@@ -189,11 +182,11 @@ def test_control_server_and_client_roundtrip(tmp_path: Path) -> None:
return {"ok": True, "offset_ms": self.offset, "lyrics_status": "idle"}
socket_path = tmp_path / "watch.sock"
server = ControlServer(socket_path=socket_path, options=TEST_WATCH_OPTIONS)
server = ControlServer(socket_path=socket_path, config=TEST_CONFIG)
session = _Session()
await server.start(session)
client = ControlClient(socket_path=socket_path, options=TEST_WATCH_OPTIONS)
await server.start(session) # type: ignore
client = ControlClient(socket_path=socket_path, config=TEST_CONFIG)
r1 = await client._send_async({"cmd": "offset", "delta": 200})
r2 = await client._send_async({"cmd": "status"})
await server.stop()
@@ -327,23 +320,23 @@ def test_session_fetches_on_resume_playing_without_lyrics() -> None:
async def _on_result(_lyrics) -> None:
return None
super().__init__(_fetch, _on_fetching, _on_result, TEST_WATCH_OPTIONS)
super().__init__(_fetch, _on_fetching, _on_result, TEST_CONFIG)
self.requested = []
def request(self, track: TrackMeta) -> None:
self.requested.append(track.display_name())
session = WatchCoordinator(
_Manager(),
_Manager(), # type: ignore
_Output(),
player_hint=None,
options=TEST_WATCH_OPTIONS,
config=TEST_CONFIG,
)
fake_fetcher = _Fetcher()
session._fetcher = fake_fetcher
session._tracker = PositionTracker(
lambda _bus: asyncio.sleep(0, result=0),
TEST_WATCH_OPTIONS,
TEST_CONFIG,
)
bus_name = "org.mpris.MediaPlayer2.spotify"
@@ -379,14 +372,14 @@ def test_session_emit_state_only_when_lyric_cursor_changes() -> None:
output = _Output()
session = WatchCoordinator(
_Manager(),
_Manager(), # type: ignore
output,
player_hint=None,
options=TEST_WATCH_OPTIONS,
config=TEST_CONFIG,
)
session._tracker = PositionTracker(
lambda _bus: asyncio.sleep(0, result=0),
TEST_WATCH_OPTIONS,
TEST_CONFIG,
)
bus_name = "org.mpris.MediaPlayer2.spotify"
@@ -429,14 +422,14 @@ def test_session_emits_when_crossing_first_timestamp() -> None:
output = _Output()
session = WatchCoordinator(
_Manager(),
_Manager(), # type: ignore
output,
player_hint=None,
options=TEST_WATCH_OPTIONS,
config=TEST_CONFIG,
)
session._tracker = PositionTracker(
lambda _bus: asyncio.sleep(0, result=0),
TEST_WATCH_OPTIONS,
TEST_CONFIG,
)
bus_name = "org.mpris.MediaPlayer2.spotify"
Generated
+33 -44
View File
@@ -43,7 +43,7 @@ wheels = [
[[package]]
name = "cyclopts"
version = "4.10.1"
version = "4.10.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
@@ -51,9 +51,9 @@ dependencies = [
{ name = "rich" },
{ name = "rich-rst" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6c/c4/2ce2ca1451487dc7d59f09334c3fa1182c46cfcf0a2d5f19f9b26d53ac74/cyclopts-4.10.1.tar.gz", hash = "sha256:ad4e4bb90576412d32276b14a76f55d43353753d16217f2c3cd5bdceba7f15a0", size = 166623, upload-time = "2026-03-23T14:43:01.098Z" }
sdist = { url = "https://files.pythonhosted.org/packages/66/2c/fced34890f6e5a93a4b7afb2c71e8eee2a0719fb26193a0abf159ecb714d/cyclopts-4.10.2.tar.gz", hash = "sha256:d7b950457ef2563596d56331f80cbbbf86a2772535fb8b315c4f03bc7e6127f1", size = 166664, upload-time = "2026-04-08T23:57:45.805Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0b/2261922126b2e50c601fe22d7ff5194e0a4d50e654836260c0665e24d862/cyclopts-4.10.1-py3-none-any.whl", hash = "sha256:35f37257139380a386d9fe4475e1e7c87ca7795765ef4f31abba579fcfcb6ecd", size = 204331, upload-time = "2026-03-23T14:43:02.625Z" },
{ url = "https://files.pythonhosted.org/packages/b4/bd/05055d8360cef0757d79367157f3b15c0a0715e81e08f86a04018ec045f0/cyclopts-4.10.2-py3-none-any.whl", hash = "sha256:a1f2d6f8f7afac9456b48f75a40b36658778ddc9c6d406b520d017ae32c990fe", size = 204314, upload-time = "2026-04-08T23:57:46.969Z" },
]
[[package]]
@@ -153,7 +153,7 @@ wheels = [
[[package]]
name = "lrx-cli"
version = "0.6.4"
version = "0.6.5"
source = { editable = "." }
dependencies = [
{ name = "cyclopts" },
@@ -162,7 +162,6 @@ dependencies = [
{ name = "loguru" },
{ name = "mutagen" },
{ name = "platformdirs" },
{ name = "python-dotenv" },
]
[package.dev-dependencies]
@@ -178,8 +177,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "mutagen", specifier = ">=1.47.0" },
{ name = "platformdirs", specifier = ">=4.9.4" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "platformdirs", specifier = ">=4.9.6" },
]
[package.metadata.requires-dev]
@@ -229,11 +227,11 @@ wheels = [
[[package]]
name = "platformdirs"
version = "4.9.4"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]]
@@ -247,16 +245,16 @@ wheels = [
[[package]]
name = "pygments"
version = "2.19.2"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -265,18 +263,9 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
@@ -307,27 +296,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.15.8"
version = "0.15.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" },
{ url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" },
{ url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" },
{ url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" },
{ url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" },
{ url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" },
{ url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" },
{ url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" },
{ url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" },
{ url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" },
{ url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" },
{ url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" },
{ url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" },
{ url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" },
{ url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
{ url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
{ url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
{ url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
{ url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
{ url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
{ url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
{ url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
{ url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
{ url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
{ url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
{ url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
{ url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
{ url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
{ url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
{ url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
]
[[package]]