diff --git a/README.md b/README.md index 3b9be18..cec6f9a 100644 --- a/README.md +++ b/README.md @@ -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,50 +73,71 @@ 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 - lrx cache stats # statistics - lrx cache query # inspect cache entries for current track - lrx cache clear # clear cache of current track - lrx cache clear --all # clear entire cache - lrx cache confidence spotify 100 # manually set confidence for a source + lrx cache stats # statistics + lrx cache query # inspect cache entries for current track + lrx cache clear # clear cache of current track + lrx cache clear --all # clear entire cache + 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 /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: diff --git a/pyproject.toml b/pyproject.toml index fbb66aa..504b004 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/lrx_cli/authenticators/__init__.py b/src/lrx_cli/authenticators/__init__.py index 07db8e3..d48ed1c 100644 --- a/src/lrx_cli/authenticators/__init__.py +++ b/src/lrx_cli/authenticators/__init__.py @@ -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), } diff --git a/src/lrx_cli/authenticators/base.py b/src/lrx_cli/authenticators/base.py index 1196b09..38b4999 100644 --- a/src/lrx_cli/authenticators/base.py +++ b/src/lrx_cli/authenticators/base.py @@ -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: ... diff --git a/src/lrx_cli/authenticators/musixmatch.py b/src/lrx_cli/authenticators/musixmatch.py index f6f4252..c0fa5b7 100644 --- a/src/lrx_cli/authenticators/musixmatch.py +++ b/src/lrx_cli/authenticators/musixmatch.py @@ -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) diff --git a/src/lrx_cli/authenticators/qqmusic.py b/src/lrx_cli/authenticators/qqmusic.py index 0a78fe7..e584da4 100644 --- a/src/lrx_cli/authenticators/qqmusic.py +++ b/src/lrx_cli/authenticators/qqmusic.py @@ -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 diff --git a/src/lrx_cli/authenticators/spotify.py b/src/lrx_cli/authenticators/spotify.py index 99d0450..ad41b05 100644 --- a/src/lrx_cli/authenticators/spotify.py +++ b/src/lrx_cli/authenticators/spotify.py @@ -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}") diff --git a/src/lrx_cli/cli.py b/src/lrx_cli/cli.py index fc8f19e..74ec017 100644 --- a/src/lrx_cli/cli.py +++ b/src/lrx_cli/cli.py @@ -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 "", ) 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) diff --git a/src/lrx_cli/config.py b/src/lrx_cli/config.py index 5994b3d..0c0523c 100644 --- a/src/lrx_cli/config.py +++ b/src/lrx_cli/config.py @@ -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 = ( "{time:YYYY-MM-DD HH:mm:ss} | " "{level: <8} | " diff --git a/src/lrx_cli/core.py b/src/lrx_cli/core.py index d005bc9..71e0926 100644 --- a/src/lrx_cli/core.py +++ b/src/lrx_cli/core.py @@ -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( diff --git a/src/lrx_cli/fetchers/__init__.py b/src/lrx_cli/fetchers/__init__.py index ea6c4ea..f9ed8bb 100644 --- a/src/lrx_cli/fetchers/__init__.py +++ b/src/lrx_cli/fetchers/__init__.py @@ -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( diff --git a/src/lrx_cli/fetchers/base.py b/src/lrx_cli/fetchers/base.py index a716e47..712f476 100644 --- a/src/lrx_cli/fetchers/base.py +++ b/src/lrx_cli/fetchers/base.py @@ -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: diff --git a/src/lrx_cli/fetchers/lrclib.py b/src/lrx_cli/fetchers/lrclib.py index 0aeb1b5..96dcc97 100644 --- a/src/lrx_cli/fetchers/lrclib.py +++ b/src/lrx_cli/fetchers/lrclib.py @@ -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: diff --git a/src/lrx_cli/fetchers/lrclib_search.py b/src/lrx_cli/fetchers/lrclib_search.py index 2ca00f2..0d54b5a 100644 --- a/src/lrx_cli/fetchers/lrclib_search.py +++ b/src/lrx_cli/fetchers/lrclib_search.py @@ -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)}" diff --git a/src/lrx_cli/fetchers/musixmatch.py b/src/lrx_cli/fetchers/musixmatch.py index 2fb602c..96efc7a 100644 --- a/src/lrx_cli/fetchers/musixmatch.py +++ b/src/lrx_cli/fetchers/musixmatch.py @@ -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: diff --git a/src/lrx_cli/fetchers/netease.py b/src/lrx_cli/fetchers/netease.py index f9e5eff..0621298 100644 --- a/src/lrx_cli/fetchers/netease.py +++ b/src/lrx_cli/fetchers/netease.py @@ -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, diff --git a/src/lrx_cli/fetchers/qqmusic.py b/src/lrx_cli/fetchers/qqmusic.py index ed41f32..04f7ceb 100644 --- a/src/lrx_cli/fetchers/qqmusic.py +++ b/src/lrx_cli/fetchers/qqmusic.py @@ -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() diff --git a/src/lrx_cli/fetchers/spotify.py b/src/lrx_cli/fetchers/spotify.py index 0e9f102..efc032c 100644 --- a/src/lrx_cli/fetchers/spotify.py +++ b/src/lrx_cli/fetchers/spotify.py @@ -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: diff --git a/src/lrx_cli/mpris.py b/src/lrx_cli/mpris.py index 1d9e12e..01b63c4 100644 --- a/src/lrx_cli/mpris.py +++ b/src/lrx_cli/mpris.py @@ -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 diff --git a/src/lrx_cli/watch/control.py b/src/lrx_cli/watch/control.py index ad020e2..63119c3 100644 --- a/src/lrx_cli/watch/control.py +++ b/src/lrx_cli/watch/control.py @@ -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)) diff --git a/src/lrx_cli/watch/fetcher.py b/src/lrx_cli/watch/fetcher.py index e0f0cdd..a3e1e3b 100644 --- a/src/lrx_cli/watch/fetcher.py +++ b/src/lrx_cli/watch/fetcher.py @@ -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 diff --git a/src/lrx_cli/watch/options.py b/src/lrx_cli/watch/options.py deleted file mode 100644 index 1dc5358..0000000 --- a/src/lrx_cli/watch/options.py +++ /dev/null @@ -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 diff --git a/src/lrx_cli/watch/player.py b/src/lrx_cli/watch/player.py index ccf3a8a..7db79e3 100644 --- a/src/lrx_cli/watch/player.py +++ b/src/lrx_cli/watch/player.py @@ -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: diff --git a/src/lrx_cli/watch/session.py b/src/lrx_cli/watch/session.py index 983b3f7..7e0febe 100644 --- a/src/lrx_cli/watch/session.py +++ b/src/lrx_cli/watch/session.py @@ -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() diff --git a/src/lrx_cli/watch/tracker.py b/src/lrx_cli/watch/tracker.py index c0438bf..bd565af 100644 --- a/src/lrx_cli/watch/tracker.py +++ b/src/lrx_cli/watch/tracker.py @@ -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 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index caab05f..4f0aabb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/marks.py b/tests/marks.py index af345a6..93d92ee 100644 --- a/tests/marks.py +++ b/tests/marks.py @@ -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", ) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3a18dca --- /dev/null +++ b/tests/test_config.py @@ -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] diff --git a/tests/test_fetchers.py b/tests/test_fetchers.py index 8ae1ec3..411aebd 100644 --- a/tests/test_fetchers.py +++ b/tests/test_fetchers.py @@ -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): diff --git a/tests/test_local.py b/tests/test_local.py new file mode 100644 index 0000000..6ac9eaa --- /dev/null +++ b/tests/test_local.py @@ -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 diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index e00e465..80a5d79 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -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" diff --git a/tests/test_watch.py b/tests/test_watch.py index c381cb3..b639dea 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -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" diff --git a/uv.lock b/uv.lock index e6ea960..1033033 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]