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) 1. **Local** — sidecar `.lrc` files or embedded audio metadata (FLAC, MP3)
2. **Cache Search** — fuzzy cross-album lookup in local cache 2. **Cache Search** — fuzzy cross-album lookup in local cache
3. **Spotify** — synced lyrics via Spotify's API 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) 4. **LRCLIB** — exact match from [lrclib.net](https://lrclib.net)
(requires full metadata) (requires full metadata)
5. **Musixmatch (Spotify)** — Musixmatch API with Spotify trackid 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) 7. **Musixmatch** — Musixmatch API with metadata search (requires at least a title)
8. **Netease** — Netease Cloud Music public API 8. **Netease** — Netease Cloud Music public API
9. **QQ Music** — QQ Music via self-hosted API proxy 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 > 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 > 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 lrx fetch
``` ```
targeting a specific player and a source to fetch from: targeting a specific player and source:
```bash ```bash
lrx fetch --player mpd --method lrclib-search 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 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: - Cache management:
```bash ```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 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): Shell completion (zsh/fish/bash):
```bash ```bash
lrx --install-completion 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 ## Development
Clone this repository: Clone this repository:
+2 -3
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "lrx-cli" name = "lrx-cli"
version = "0.6.4" version = "0.6.5"
description = "Fetch line-synced lyrics for your music player." description = "Fetch line-synced lyrics for your music player."
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
@@ -14,8 +14,7 @@ dependencies = [
"httpx>=0.28.1", "httpx>=0.28.1",
"loguru>=0.7.3", "loguru>=0.7.3",
"mutagen>=1.47.0", "mutagen>=1.47.0",
"platformdirs>=4.9.4", "platformdirs>=4.9.6",
"python-dotenv>=1.2.2",
] ]
[project.scripts] [project.scripts]
+9 -6
View File
@@ -10,6 +10,7 @@ from .base import BaseAuthenticator
from .spotify import SpotifyAuthenticator from .spotify import SpotifyAuthenticator
from .musixmatch import MusixmatchAuthenticator from .musixmatch import MusixmatchAuthenticator
from .dummy import DummyAuthenticator from .dummy import DummyAuthenticator
from ..config import AppConfig
__all__ = [ __all__ = [
"BaseAuthenticator", "BaseAuthenticator",
@@ -20,11 +21,13 @@ __all__ = [
] ]
def create_authenticators(cache) -> dict[str, BaseAuthenticator]: def create_authenticators(cache, config: AppConfig) -> dict[str, BaseAuthenticator]:
"""Factory function to create authenticators with cache access.""" """Factory function to create authenticators with injected config."""
return { return {
"dummy": DummyAuthenticator(), "dummy": DummyAuthenticator(cache, config.credentials, config.general),
"spotify": SpotifyAuthenticator(cache), "spotify": SpotifyAuthenticator(cache, config.credentials, config.general),
"musixmatch": MusixmatchAuthenticator(cache), "musixmatch": MusixmatchAuthenticator(
"qqmusic": QQMusicAuthenticator(), 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 abc import ABC, abstractmethod
from typing import Optional from typing import Optional
from ..cache import CacheEngine
from ..config import CredentialConfig, GeneralConfig
class BaseAuthenticator(ABC): class BaseAuthenticator(ABC):
"""Manages obtaining, caching, and refreshing a credential for one provider.""" """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 @property
@abstractmethod @abstractmethod
def name(self) -> str: ... def name(self) -> str: ...
+9 -7
View File
@@ -12,7 +12,7 @@ from loguru import logger
from .base import BaseAuthenticator from .base import BaseAuthenticator
from ..cache import CacheEngine 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" _MUSIXMATCH_TOKEN_URL = "https://apic-desktop.musixmatch.com/ws/1.1/token.get"
@@ -24,8 +24,10 @@ _MXM_BASE_PARAMS = {
class MusixmatchAuthenticator(BaseAuthenticator): class MusixmatchAuthenticator(BaseAuthenticator):
def __init__(self, cache: CacheEngine) -> None: def __init__(
self._cache = cache self, cache: CacheEngine, credentials: CredentialConfig, general: GeneralConfig
) -> None:
super().__init__(cache, credentials, general)
self._cached_token: Optional[str] = None self._cached_token: Optional[str] = None
self._cooldown_until_ms: int = 0 self._cooldown_until_ms: int = 0
@@ -77,7 +79,7 @@ class MusixmatchAuthenticator(BaseAuthenticator):
logger.debug("Musixmatch: fetching anonymous token") logger.debug("Musixmatch: fetching anonymous token")
try: 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 = await client.get(url, headers=_MXM_HEADERS)
resp.raise_for_status() resp.raise_for_status()
data = resp.json() data = resp.json()
@@ -102,8 +104,8 @@ class MusixmatchAuthenticator(BaseAuthenticator):
async def _get_token(self) -> Optional[str]: async def _get_token(self) -> Optional[str]:
"""Return a valid token: env var > memory > DB > fresh fetch.""" """Return a valid token: env var > memory > DB > fresh fetch."""
if credentials.MUSIXMATCH_USERTOKEN: if self._credentials.musixmatch_usertoken:
return credentials.MUSIXMATCH_USERTOKEN return self._credentials.musixmatch_usertoken
if self._cached_token: if self._cached_token:
return self._cached_token return self._cached_token
@@ -139,7 +141,7 @@ class MusixmatchAuthenticator(BaseAuthenticator):
self._set_cooldown() self._set_cooldown()
return None 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})}" url = f"{url_base}?{urlencode({**_MXM_BASE_PARAMS, **params, 'usertoken': token})}"
resp = await client.get(url, headers=_MXM_HEADERS) 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 typing import Optional
from .base import BaseAuthenticator from .base import BaseAuthenticator
from ..config import credentials from ..cache import CacheEngine
from ..config import CredentialConfig, GeneralConfig
class QQMusicAuthenticator(BaseAuthenticator): class QQMusicAuthenticator(BaseAuthenticator):
def __init__(self) -> None: def __init__(
pass self, cache: CacheEngine, credentials: CredentialConfig, general: GeneralConfig
) -> None:
super().__init__(cache, credentials, general)
@property @property
def name(self) -> str: def name(self) -> str:
return "qqmusic" return "qqmusic"
def is_configured(self) -> bool: 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]: 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 .base import BaseAuthenticator
from ..cache import CacheEngine 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_TOKEN_URL = "https://open.spotify.com/api/token"
_SPOTIFY_SERVER_TIME_URL = "https://open.spotify.com/api/server-time" _SPOTIFY_SERVER_TIME_URL = "https://open.spotify.com/api/server-time"
@@ -32,8 +32,10 @@ SPOTIFY_BASE_HEADERS = {
class SpotifyAuthenticator(BaseAuthenticator): class SpotifyAuthenticator(BaseAuthenticator):
def __init__(self, cache: CacheEngine) -> None: def __init__(
self._cache = cache self, cache: CacheEngine, credentials: CredentialConfig, general: GeneralConfig
) -> None:
super().__init__(cache, credentials, general)
self._cached_secret: Optional[Tuple[str, int]] = None self._cached_secret: Optional[Tuple[str, int]] = None
self._cached_token: Optional[str] = None self._cached_token: Optional[str] = None
self._token_expires_at: float = 0.0 self._token_expires_at: float = 0.0
@@ -43,7 +45,7 @@ class SpotifyAuthenticator(BaseAuthenticator):
return "spotify" return "spotify"
def is_configured(self) -> bool: def is_configured(self) -> bool:
return bool(credentials.SPOTIFY_SP_DC) return bool(self._credentials.spotify_sp_dc)
@staticmethod @staticmethod
def _generate_totp(server_time_s: int, secret: str) -> str: 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]: async def _get_server_time(self, client: httpx.AsyncClient) -> Optional[int]:
try: 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() res.raise_for_status()
data = res.json() data = res.json()
if not isinstance(data, dict) or "serverTime" not in data: if not isinstance(data, dict) or "serverTime" not in data:
@@ -100,7 +104,9 @@ class SpotifyAuthenticator(BaseAuthenticator):
logger.debug("Spotify: using cached TOTP secret") logger.debug("Spotify: using cached TOTP secret")
return self._cached_secret return self._cached_secret
try: 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() res.raise_for_status()
data = res.json() data = res.json()
if not isinstance(data, list) or len(data) == 0: 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: if db_token and time.time() < self._token_expires_at - 30:
return db_token return db_token
if not credentials.SPOTIFY_SP_DC: if not self._credentials.spotify_sp_dc:
logger.error("Spotify: SPOTIFY_SP_DC env var not set — cannot authenticate") logger.error("Spotify: spotify_sp_dc not configured — cannot authenticate")
return None return None
headers = { headers = {
"Accept": "*/*", "Accept": "*/*",
"Cookie": f"sp_dc={credentials.SPOTIFY_SP_DC}", "Cookie": f"sp_dc={self._credentials.spotify_sp_dc}",
**SPOTIFY_BASE_HEADERS, **SPOTIFY_BASE_HEADERS,
} }
@@ -166,7 +172,9 @@ class SpotifyAuthenticator(BaseAuthenticator):
try: try:
res = await client.get( 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: if res.status_code != 200:
logger.error(f"Spotify: token request returned {res.status_code}") 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 ( from .config import (
DB_PATH, DB_PATH,
PLAYER_BLACKLIST, AppConfig,
PREFERRED_PLAYER, load_config,
WATCH_CALIBRATION_INTERVAL_S,
WATCH_DEBOUNCE_MS,
WATCH_POSITION_TICK_MS,
WATCH_SOCKET_PATH,
enable_debug, enable_debug,
) )
from .models import TrackMeta from .models import TrackMeta
@@ -32,7 +28,6 @@ from .fetchers import FetcherMethodType
from .lrc import get_sidecar_path from .lrc import get_sidecar_path
from .watch import WatchCoordinator from .watch import WatchCoordinator
from .watch.control import ControlClient, parse_delta from .watch.control import ControlClient, parse_delta
from .watch.options import WatchOptions
from .watch.view.pipe import PipeOutput from .watch.view.pipe import PipeOutput
@@ -54,23 +49,12 @@ watch_app.command(ctl_app)
# Global state set by the meta launcher # Global state set by the meta launcher
_player: str | None = None _player: str | None = None
_db_path: 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 # Will be initialized before any command runs, safe to set to None here
manager: LrcManager = None # type: ignore 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 @app.meta.default
def launcher( def launcher(
*tokens: Annotated[str, cyclopts.Parameter(show=False, allow_leading_hyphen=True)], *tokens: Annotated[str, cyclopts.Parameter(show=False, allow_leading_hyphen=True)],
@@ -95,13 +79,13 @@ def launcher(
), ),
] = None, ] = None,
): ):
global _player, _db_path global _player, _db_path, _app_config, manager
if debug: if debug:
enable_debug() enable_debug()
_player = player _player = player
_db_path = str(Path(db_path).resolve()) if db_path else DB_PATH _db_path = str(Path(db_path).resolve()) if db_path else DB_PATH
global manager _app_config = load_config()
manager = LrcManager(db_path=_db_path) manager = LrcManager(db_path=_db_path, config=_app_config)
app(tokens) app(tokens)
@@ -147,7 +131,11 @@ def fetch(
] = False, ] = False,
): ):
"""Fetch and print lyrics for the currently playing track.""" """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: if not track:
logger.error("No active playing track found.") logger.error("No active playing track found.")
@@ -331,7 +319,11 @@ def export(
] = False, ] = False,
): ):
"""Export lyrics of the current track to a .lrc file.""" """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: if not track:
logger.error("No active playing track found.") logger.error("No active playing track found.")
sys.exit(1) sys.exit(1)
@@ -416,13 +408,12 @@ def pipe(
_player or "<none>", _player or "<none>",
) )
output = PipeOutput(before=max(0, before), after=max(0, after)) output = PipeOutput(before=max(0, before), after=max(0, after))
options = _build_watch_options()
try: try:
session = WatchCoordinator( session = WatchCoordinator(
manager, manager,
output, output,
player_hint=_player, player_hint=_player,
options=options, config=_app_config,
) )
success = asyncio.run(session.run()) success = asyncio.run(session.run())
if not success: if not success:
@@ -439,7 +430,7 @@ def offset(delta: str) -> None:
logger.error(parse_error or "Invalid offset delta") logger.error(parse_error or "Invalid offset delta")
sys.exit(1) sys.exit(1)
response = ControlClient(options=_build_watch_options()).send( response = ControlClient(config=_app_config).send(
{"cmd": "offset", "delta": parsed_delta} {"cmd": "offset", "delta": parsed_delta}
) )
if not response.get("ok"): if not response.get("ok"):
@@ -451,7 +442,7 @@ def offset(delta: str) -> None:
@ctl_app.command @ctl_app.command
def status() -> None: def status() -> None:
"""Print current watch session status as JSON.""" """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"): if not response.get("ok"):
logger.error(response.get("error", "Unknown error")) logger.error(response.get("error", "Unknown error"))
sys.exit(1) sys.exit(1)
@@ -480,7 +471,11 @@ def query(
print() print()
return 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: if not track:
logger.error("No active playing track found.") logger.error("No active playing track found.")
sys.exit(1) sys.exit(1)
@@ -500,7 +495,11 @@ def clear(
manager.cache.clear_all() manager.cache.clear_all()
return 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: if not track:
logger.error("No active playing track found.") logger.error("No active playing track found.")
sys.exit(1) sys.exit(1)
@@ -590,7 +589,11 @@ def confidence(
logger.error("Score must be between 0 and 100.") logger.error("Score must be between 0 and 100.")
sys.exit(1) 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: if not track:
logger.error("No active playing track found.") logger.error("No active playing track found.")
sys.exit(1) sys.exit(1)
@@ -614,7 +617,11 @@ def insert(
] = None, ] = None,
): ):
"""Manually insert lyrics into the cache for the current track.""" """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: if not track:
logger.error("No active playing track found.") logger.error("No active playing track found.")
sys.exit(1) sys.exit(1)
+130 -48
View File
@@ -1,14 +1,18 @@
""" """
Author: Uyanide pywang0608@foxmail.com Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 10:17:56 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 os
import sys import sys
import tomllib
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Any, get_type_hints
from platformdirs import user_cache_dir, user_config_dir from platformdirs import user_cache_dir, user_config_dir
from dotenv import load_dotenv
from loguru import logger from loguru import logger
from importlib.metadata import version from importlib.metadata import version
@@ -24,13 +28,7 @@ DB_PATH = os.path.join(CACHE_DIR, "cache.db")
SLOT_SYNCED = "SYNCED" SLOT_SYNCED = "SYNCED"
SLOT_UNSYNCED = "UNSYNCED" SLOT_UNSYNCED = "UNSYNCED"
# .env loading _WATCH_SOCKET_PATH = str(Path(CACHE_DIR) / "watch.sock")
_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
# Cache TTLs (seconds) # Cache TTLs (seconds)
TTL_SYNCED = None # never expires 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 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) 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 = ( _LOG_FORMAT = (
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | " "<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <8}</level> | " "<level>{level: <8}</level> | "
+4 -3
View File
@@ -22,6 +22,7 @@ from .config import (
HIGH_CONFIDENCE, HIGH_CONFIDENCE,
SLOT_SYNCED, SLOT_SYNCED,
SLOT_UNSYNCED, SLOT_UNSYNCED,
AppConfig,
) )
from .models import TrackMeta, LyricResult, CacheStatus from .models import TrackMeta, LyricResult, CacheStatus
from .enrichers import create_enrichers, enrich_track from .enrichers import create_enrichers, enrich_track
@@ -92,10 +93,10 @@ def _has_negative_for_both_slots(cached_rows: list[LyricResult]) -> bool:
class LrcManager: class LrcManager:
"""Main entry point for fetching lyrics with caching.""" """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.cache = CacheEngine(db_path=db_path)
self.authenticators = create_authenticators(self.cache) self.authenticators = create_authenticators(self.cache, config)
self.fetchers = create_fetchers(self.cache, self.authenticators) self.fetchers = create_fetchers(self.cache, self.authenticators, config)
self.enrichers = create_enrichers(self.authenticators) self.enrichers = create_enrichers(self.authenticators)
async def _run_group( async def _run_group(
+13 -11
View File
@@ -23,6 +23,7 @@ from ..authenticators import (
QQMusicAuthenticator, QQMusicAuthenticator,
) )
from ..cache import CacheEngine from ..cache import CacheEngine
from ..config import AppConfig
from ..models import TrackMeta from ..models import TrackMeta
FetcherMethodType = Literal[ FetcherMethodType = Literal[
@@ -52,26 +53,27 @@ _FETCHER_GROUPS: list[list[FetcherMethodType]] = [
def create_fetchers( def create_fetchers(
cache: CacheEngine, cache: CacheEngine,
authenticators: dict[str, BaseAuthenticator], authenticators: dict[str, BaseAuthenticator],
config: AppConfig,
) -> dict[FetcherMethodType, BaseFetcher]: ) -> dict[FetcherMethodType, BaseFetcher]:
"""Instantiate all fetchers. Returns a dict keyed by source name.""" """Instantiate all fetchers. Returns a dict keyed by source name."""
spotify_auth = authenticators["spotify"] spotify_auth = authenticators["spotify"]
mxm_auth = authenticators["musixmatch"] mxm_auth = authenticators["musixmatch"]
qqmusic_auth = authenticators.get("qqmusic") qqmusic_auth = authenticators["qqmusic"]
assert isinstance(spotify_auth, SpotifyAuthenticator) assert isinstance(spotify_auth, SpotifyAuthenticator)
assert isinstance(mxm_auth, MusixmatchAuthenticator) assert isinstance(mxm_auth, MusixmatchAuthenticator)
assert isinstance(qqmusic_auth, QQMusicAuthenticator) assert isinstance(qqmusic_auth, QQMusicAuthenticator)
fetchers: dict[FetcherMethodType, BaseFetcher] = { g = config.general
"local": LocalFetcher(), return {
"local": LocalFetcher(g),
"cache-search": CacheSearchFetcher(cache), "cache-search": CacheSearchFetcher(cache),
"spotify": SpotifyFetcher(spotify_auth), "spotify": SpotifyFetcher(g, spotify_auth),
"lrclib": LrclibFetcher(), "lrclib": LrclibFetcher(g),
"musixmatch-spotify": MusixmatchSpotifyFetcher(mxm_auth), "musixmatch-spotify": MusixmatchSpotifyFetcher(g, mxm_auth),
"lrclib-search": LrclibSearchFetcher(), "lrclib-search": LrclibSearchFetcher(g),
"netease": NeteaseFetcher(), "netease": NeteaseFetcher(g),
"qqmusic": QQMusicFetcher(qqmusic_auth), "qqmusic": QQMusicFetcher(g, qqmusic_auth),
"musixmatch": MusixmatchFetcher(mxm_auth), "musixmatch": MusixmatchFetcher(g, mxm_auth),
} }
return fetchers
def build_plan( def build_plan(
+8
View File
@@ -8,6 +8,8 @@ from abc import ABC, abstractmethod
from typing import Optional from typing import Optional
from dataclasses import dataclass from dataclasses import dataclass
from ..authenticators.base import BaseAuthenticator
from ..config import GeneralConfig
from ..models import CacheStatus, TrackMeta, LyricResult from ..models import CacheStatus, TrackMeta, LyricResult
@@ -38,6 +40,12 @@ class FetchResult:
class BaseFetcher(ABC): class BaseFetcher(ABC):
def __init__(
self, general: GeneralConfig, auth: Optional[BaseAuthenticator] = None
) -> None:
self._general = general
self._auth = auth
@property @property
@abstractmethod @abstractmethod
def source_name(self) -> str: 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 ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData from ..lrc import LRCData
from ..config import ( from ..config import (
HTTP_TIMEOUT,
TTL_UNSYNCED, TTL_UNSYNCED,
TTL_NOT_FOUND, TTL_NOT_FOUND,
UA_LRX, UA_LRX,
@@ -46,7 +45,7 @@ class LrclibFetcher(BaseFetcher):
logger.info(f"LRCLIB: fetching lyrics for {track.display_name()}") logger.info(f"LRCLIB: fetching lyrics for {track.display_name()}")
try: 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}) resp = await client.get(url, headers={"User-Agent": UA_LRX})
if resp.status_code == 404: 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 ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData from ..lrc import LRCData
from ..config import ( from ..config import (
HTTP_TIMEOUT,
TTL_UNSYNCED, TTL_UNSYNCED,
TTL_NOT_FOUND, TTL_NOT_FOUND,
UA_LRX, UA_LRX,
@@ -73,7 +72,7 @@ class LrclibSearchFetcher(BaseFetcher):
had_error = False had_error = False
try: 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]: async def _query(params: dict[str, str]) -> tuple[list[dict], bool]:
url = f"{_LRCLIB_SEARCH_URL}?{urlencode(params)}" 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 .base import BaseFetcher, FetchResult
from .selection import SearchCandidate, select_best from .selection import SearchCandidate, select_best
from ..authenticators.musixmatch import MusixmatchAuthenticator from ..authenticators.musixmatch import MusixmatchAuthenticator
from ..config import GeneralConfig
from ..lrc import LRCData from ..lrc import LRCData
from ..models import CacheStatus, LyricResult, TrackMeta from ..models import CacheStatus, LyricResult, TrackMeta
@@ -145,22 +146,24 @@ async def _fetch_macro(
class MusixmatchSpotifyFetcher(BaseFetcher): class MusixmatchSpotifyFetcher(BaseFetcher):
"""Direct lookup by Spotify track ID — no search, single request.""" """Direct lookup by Spotify track ID — no search, single request."""
def __init__(self, auth: MusixmatchAuthenticator) -> None: _auth: MusixmatchAuthenticator
self.auth = auth
def __init__(self, general: GeneralConfig, auth: MusixmatchAuthenticator) -> None:
super().__init__(general, auth)
@property @property
def source_name(self) -> str: def source_name(self) -> str:
return "musixmatch-spotify" return "musixmatch-spotify"
def is_available(self, track: TrackMeta) -> bool: 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: async def fetch(self, track: TrackMeta, bypass_cache: bool = False) -> FetchResult:
logger.info(f"Musixmatch-Spotify: fetching lyrics for {track.display_name()}") logger.info(f"Musixmatch-Spotify: fetching lyrics for {track.display_name()}")
try: try:
lrc = await _fetch_macro( lrc = await _fetch_macro(
self.auth, self._auth,
{"track_spotify_id": track.trackid}, # type: ignore[dict-item] {"track_spotify_id": track.trackid}, # type: ignore[dict-item]
) )
except AttributeError: except AttributeError:
@@ -191,8 +194,10 @@ class MusixmatchSpotifyFetcher(BaseFetcher):
class MusixmatchFetcher(BaseFetcher): class MusixmatchFetcher(BaseFetcher):
"""Metadata search + best-candidate lyric fetch.""" """Metadata search + best-candidate lyric fetch."""
def __init__(self, auth: MusixmatchAuthenticator) -> None: _auth: MusixmatchAuthenticator
self.auth = auth
def __init__(self, general: GeneralConfig, auth: MusixmatchAuthenticator) -> None:
super().__init__(general, auth)
@property @property
def source_name(self) -> str: def source_name(self) -> str:
@@ -203,7 +208,7 @@ class MusixmatchFetcher(BaseFetcher):
return "musixmatch" return "musixmatch"
def is_available(self, track: TrackMeta) -> bool: 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]: async def _search(self, track: TrackMeta) -> tuple[Optional[int], float]:
"""Search for track metadata. Raises on network/HTTP errors.""" """Search for track metadata. Raises on network/HTTP errors."""
@@ -218,7 +223,7 @@ class MusixmatchFetcher(BaseFetcher):
params["q_album"] = track.album params["q_album"] = track.album
logger.debug(f"Musixmatch: searching for '{track.display_name()}'") 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: if data is None:
return None, 0.0 return None, 0.0
@@ -270,7 +275,7 @@ class MusixmatchFetcher(BaseFetcher):
return FetchResult.from_not_found() return FetchResult.from_not_found()
lrc = await _fetch_macro( lrc = await _fetch_macro(
self.auth, self._auth,
{"commontrack_id": str(commontrack_id)}, {"commontrack_id": str(commontrack_id)},
) )
except AttributeError: except AttributeError:
+2 -3
View File
@@ -16,7 +16,6 @@ from .selection import SearchCandidate, select_ranked
from ..models import TrackMeta, LyricResult, CacheStatus from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData from ..lrc import LRCData
from ..config import ( from ..config import (
HTTP_TIMEOUT,
TTL_NOT_FOUND, TTL_NOT_FOUND,
MULTI_CANDIDATE_DELAY_S, MULTI_CANDIDATE_DELAY_S,
UA_BROWSER, UA_BROWSER,
@@ -49,7 +48,7 @@ class NeteaseFetcher(BaseFetcher):
logger.debug(f"Netease: searching for '{query}' (limit={limit})") logger.debug(f"Netease: searching for '{query}' (limit={limit})")
try: 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( resp = await client.post(
_NETEASE_SEARCH_URL, _NETEASE_SEARCH_URL,
headers=_NETEASE_BASE_HEADERS, headers=_NETEASE_BASE_HEADERS,
@@ -114,7 +113,7 @@ class NeteaseFetcher(BaseFetcher):
logger.debug(f"Netease: fetching lyrics for song_id={song_id}") logger.debug(f"Netease: fetching lyrics for song_id={song_id}")
try: 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( resp = await client.post(
_NETEASE_LYRIC_URL, _NETEASE_LYRIC_URL,
headers=_NETEASE_BASE_HEADERS, 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 ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData from ..lrc import LRCData
from ..config import ( from ..config import (
HTTP_TIMEOUT, GeneralConfig,
TTL_NOT_FOUND, TTL_NOT_FOUND,
MULTI_CANDIDATE_DELAY_S, MULTI_CANDIDATE_DELAY_S,
) )
@@ -29,15 +29,17 @@ from ..authenticators import QQMusicAuthenticator
class QQMusicFetcher(BaseFetcher): class QQMusicFetcher(BaseFetcher):
def __init__(self, auth: QQMusicAuthenticator) -> None: _auth: QQMusicAuthenticator
self.auth = auth
def __init__(self, general: GeneralConfig, auth: QQMusicAuthenticator) -> None:
super().__init__(general, auth)
@property @property
def source_name(self) -> str: def source_name(self) -> str:
return "qqmusic" return "qqmusic"
def is_available(self, track: TrackMeta) -> bool: 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( async def _search(
self, track: TrackMeta, limit: int = 10 self, track: TrackMeta, limit: int = 10
@@ -49,9 +51,9 @@ class QQMusicFetcher(BaseFetcher):
logger.debug(f"QQMusic: searching for '{query}' (limit={limit})") logger.debug(f"QQMusic: searching for '{query}' (limit={limit})")
try: 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( 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}, params={"keyword": query, "type": "song", "num": limit},
) )
resp.raise_for_status() resp.raise_for_status()
@@ -106,9 +108,9 @@ class QQMusicFetcher(BaseFetcher):
logger.debug(f"QQMusic: fetching lyrics for mid={mid}") logger.debug(f"QQMusic: fetching lyrics for mid={mid}")
try: 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( 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}, params={"mid": mid},
) )
resp.raise_for_status() resp.raise_for_status()
@@ -154,7 +156,7 @@ class QQMusicFetcher(BaseFetcher):
return FetchResult.from_network_error() return FetchResult.from_network_error()
async def fetch(self, track: TrackMeta, bypass_cache: bool = False) -> FetchResult: 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") logger.debug("QQMusic: skipped — Auth not configured")
return FetchResult() return FetchResult()
+8 -6
View File
@@ -11,21 +11,23 @@ from .base import BaseFetcher, FetchResult
from ..authenticators.spotify import SpotifyAuthenticator, SPOTIFY_BASE_HEADERS from ..authenticators.spotify import SpotifyAuthenticator, SPOTIFY_BASE_HEADERS
from ..models import TrackMeta, LyricResult, CacheStatus from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData 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/" _SPOTIFY_LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/"
class SpotifyFetcher(BaseFetcher): class SpotifyFetcher(BaseFetcher):
def __init__(self, auth: SpotifyAuthenticator) -> None: def __init__(self, general: GeneralConfig, auth: SpotifyAuthenticator) -> None:
self.auth = auth super().__init__(general, auth)
_auth: SpotifyAuthenticator
@property @property
def source_name(self) -> str: def source_name(self) -> str:
return "spotify" return "spotify"
def is_available(self, track: TrackMeta) -> bool: 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 @staticmethod
def _format_lrc_line(start_ms: int, words: str) -> str: 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}") logger.info(f"Spotify: fetching lyrics for trackid={track.trackid}")
token = await self.auth.authenticate() token = await self._auth.authenticate()
if not token: if not token:
logger.error("Spotify: cannot fetch lyrics without a token") logger.error("Spotify: cannot fetch lyrics without a token")
return FetchResult.from_network_error() return FetchResult.from_network_error()
@@ -65,7 +67,7 @@ class SpotifyFetcher(BaseFetcher):
} }
try: 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) res = await client.get(url, headers=headers)
if res.status_code == 404: 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.aio.message_bus import MessageBus
from dbus_next.constants import BusType from dbus_next.constants import BusType
from dbus_next.message import Message 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.models import TrackMeta
from lrx_cli.config import PREFERRED_PLAYER
from loguru import logger from loguru import logger
from typing import Optional, List, Any from typing import Optional, List, Any
async def _list_mpris_players(bus: MessageBus) -> List[str]: async def _list_mpris_players(
"""List all MPRIS player bus names.""" bus: MessageBus,
player_blacklist: tuple[str, ...],
) -> List[str]:
"""List all MPRIS player bus names, excluding blacklisted entries."""
try: try:
reply = await bus.call( reply = await bus.call(
Message( Message(
@@ -28,7 +31,10 @@ async def _list_mpris_players(bus: MessageBus) -> List[str]:
if not reply or not reply.body: if not reply or not reply.body:
return [] return []
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: except Exception as e:
logger.error(f"Failed to list DBus names: {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( 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]: ) -> Optional[str]:
"""Select the best MPRIS player. """Select the best MPRIS player.
When specific_player is given, filter by name match. When specific_player is given, filter by name match.
Otherwise: prefer the currently playing player. If multiple are playing, 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: if not players:
return None return None
@@ -82,8 +91,8 @@ async def _select_player(
if len(candidates) == 1: if len(candidates) == 1:
return candidates[0] return candidates[0]
# Multiple candidates: prefer PREFERRED_PLAYER # Multiple candidates: prefer preferred_player
preferred = PREFERRED_PLAYER.lower() preferred = preferred_player.lower()
if preferred: if preferred:
for p in candidates: for p in candidates:
if preferred in p.lower(): if preferred in p.lower():
@@ -92,7 +101,9 @@ async def _select_player(
async def _fetch_metadata_dbus( async def _fetch_metadata_dbus(
specific_player: Optional[str] = None, specific_player: Optional[str],
preferred_player: str,
player_blacklist: tuple[str, ...],
) -> Optional[TrackMeta]: ) -> Optional[TrackMeta]:
bus = None bus = None
try: try:
@@ -102,7 +113,9 @@ async def _fetch_metadata_dbus(
return None return None
try: 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: if not player_name:
logger.debug( logger.debug(
f"No active MPRIS players found via DBus{' for ' + specific_player if specific_player else ''}." 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() 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: 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: except Exception as e:
logger.error(f"DBus async loop failed: {e}") logger.error(f"DBus async loop failed: {e}")
return None return None
+15 -35
View File
@@ -3,49 +3,32 @@
import asyncio import asyncio
import json import json
from pathlib import Path from pathlib import Path
from typing import Protocol, TypeAlias from typing import TYPE_CHECKING
from loguru import logger from loguru import logger
from .options import WatchOptions from ..config import AppConfig
if TYPE_CHECKING:
JSONPrimitive: TypeAlias = str | int | float | bool | None from .session import WatchCoordinator
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."""
...
class ControlServer: class ControlServer:
"""Control server that handles offset/status commands over a Unix socket.""" """Control server that handles offset/status commands over a Unix socket."""
_options: WatchOptions
_socket_path: Path _socket_path: Path
_server: asyncio.AbstractServer | None _server: asyncio.AbstractServer | None
def __init__( def __init__(
self, self,
options: WatchOptions, config: AppConfig,
socket_path: Path | None = None, socket_path: Path | None = None,
) -> None: ) -> None:
"""Initialize control server with explicit socket path or runtime options.""" """Initialize control server with socket path from config or explicit override."""
self._options = options self._socket_path: Path = socket_path or Path(config.watch.socket_path)
resolved_socket_path = socket_path or self._options.socket_path
self._socket_path: Path = resolved_socket_path
self._server: asyncio.AbstractServer | None = None 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.""" """Start listening for control requests and bind session handlers."""
if not await self._prepare_socket_path(): if not await self._prepare_socket_path():
return False return False
@@ -90,12 +73,12 @@ class ControlServer:
async def _handle( async def _handle(
self, self,
session: ControlSession, session: "WatchCoordinator",
reader: asyncio.StreamReader, reader: asyncio.StreamReader,
writer: asyncio.StreamWriter, writer: asyncio.StreamWriter,
) -> None: ) -> None:
"""Handle one control request and send JSON response.""" """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: try:
line = await reader.readline() line = await reader.readline()
if not line: if not line:
@@ -122,20 +105,17 @@ class ControlServer:
class ControlClient: class ControlClient:
"""Control client used by CLI commands to talk to active watch session.""" """Control client used by CLI commands to talk to active watch session."""
_options: WatchOptions
_socket_path: Path _socket_path: Path
def __init__( def __init__(
self, self,
options: WatchOptions, config: AppConfig,
socket_path: Path | None = None, socket_path: Path | None = None,
) -> None: ) -> None:
"""Initialize control client with explicit socket path or runtime options.""" """Initialize control client with socket path from config or explicit override."""
self._options = options self._socket_path: Path = socket_path or Path(config.watch.socket_path)
resolved_socket_path = socket_path or self._options.socket_path
self._socket_path: Path = resolved_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.""" """Send one JSON command to control server and return JSON response."""
if not self._socket_path.exists(): if not self._socket_path.exists():
return {"ok": False, "error": "No watch session running."} return {"ok": False, "error": "No watch session running."}
@@ -154,7 +134,7 @@ class ControlClient:
return {"ok": False, "error": "Empty response."} return {"ok": False, "error": "Empty response."}
return json.loads(line.decode("utf-8")) 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.""" """Synchronous wrapper around async control request."""
return asyncio.run(self._send_async(cmd)) return asyncio.run(self._send_async(cmd))
+5 -5
View File
@@ -3,15 +3,15 @@
import asyncio import asyncio
from typing import Awaitable, Callable, Optional from typing import Awaitable, Callable, Optional
from ..config import AppConfig
from ..lrc import LRCData from ..lrc import LRCData
from ..models import TrackMeta from ..models import TrackMeta
from .options import WatchOptions
class LyricFetcher: class LyricFetcher:
"""Debounces track updates and runs at most one lyric fetch task at a time.""" """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]]] _fetch_func: Callable[[TrackMeta], Awaitable[Optional[LRCData]]]
_on_fetching: Callable[[], Awaitable[None] | None] _on_fetching: Callable[[], Awaitable[None] | None]
_on_result: Callable[[Optional[LRCData]], Awaitable[None] | None] _on_result: Callable[[Optional[LRCData]], Awaitable[None] | None]
@@ -24,10 +24,10 @@ class LyricFetcher:
fetch_func: Callable[[TrackMeta], Awaitable[Optional[LRCData]]], fetch_func: Callable[[TrackMeta], Awaitable[Optional[LRCData]]],
on_fetching: Callable[[], Awaitable[None] | None], on_fetching: Callable[[], Awaitable[None] | None],
on_result: Callable[[Optional[LRCData]], Awaitable[None] | None], on_result: Callable[[Optional[LRCData]], Awaitable[None] | None],
options: WatchOptions, config: AppConfig,
) -> None: ) -> None:
"""Initialize fetch callbacks and runtime options.""" """Initialize fetch callbacks and runtime options."""
self._options = options self._config = config
self._fetch_func = fetch_func self._fetch_func = fetch_func
self._on_fetching = on_fetching self._on_fetching = on_fetching
self._on_result = on_result self._on_result = on_result
@@ -56,7 +56,7 @@ class LyricFetcher:
async def _debounce_then_fetch(self) -> None: async def _debounce_then_fetch(self) -> None:
"""Wait debounce window then start a fresh fetch task for latest pending track.""" """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 track = self._pending_track
if track is None: if track is None:
return 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 dbus_next.message import Message
from loguru import logger from loguru import logger
from ..config import AppConfig
from ..models import TrackMeta from ..models import TrackMeta
from .options import WatchOptions
def _variant_value(item: object) -> object | None: def _variant_value(item: object) -> object | None:
@@ -75,7 +75,7 @@ def _keyword_match(text: str, keyword: str) -> bool:
class PlayerMonitor: class PlayerMonitor:
"""Tracks MPRIS players and forwards signal-driven state updates to session callbacks.""" """Tracks MPRIS players and forwards signal-driven state updates to session callbacks."""
_options: WatchOptions _config: AppConfig
_on_players_changed: Callable[[], None] _on_players_changed: Callable[[], None]
_on_seeked: Callable[[str, int], None] _on_seeked: Callable[[str, int], None]
_on_playback_status: Callable[[str, str], None] _on_playback_status: Callable[[str, str], None]
@@ -89,16 +89,16 @@ class PlayerMonitor:
on_players_changed: Callable[[], None], on_players_changed: Callable[[], None],
on_seeked: Callable[[str, int], None], on_seeked: Callable[[str, int], None],
on_playback_status: Callable[[str, str], None], on_playback_status: Callable[[str, str], None],
options: WatchOptions, config: AppConfig,
target: Optional[PlayerTarget] = None, target: Optional[PlayerTarget] = None,
) -> None: ) -> None:
"""Initialize monitor callbacks, runtime options, and player target filter.""" """Initialize monitor callbacks, runtime options, and player target filter."""
self._options = options self._config = config
self._on_players_changed = on_players_changed self._on_players_changed = on_players_changed
self._on_seeked = on_seeked self._on_seeked = on_seeked
self._on_playback_status = on_playback_status self._on_playback_status = on_playback_status
self._target = target or PlayerTarget( self._target = target or PlayerTarget(
player_blacklist=self._options.player_blacklist player_blacklist=self._config.general.player_blacklist
) )
self.players: dict[str, PlayerState] = {} self.players: dict[str, PlayerState] = {}
self._bus: MessageBus | None = None self._bus: MessageBus | None = None
@@ -184,7 +184,8 @@ class PlayerMonitor:
if not name.startswith("org.mpris.MediaPlayer2."): if not name.startswith("org.mpris.MediaPlayer2."):
continue continue
if any( 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 continue
if not self._target.allows(name): if not self._target.allows(name):
@@ -388,7 +389,7 @@ class ActivePlayerSelector:
def select( def select(
players: dict[str, PlayerState], players: dict[str, PlayerState],
last_active: str | None, last_active: str | None,
options: WatchOptions, config: AppConfig,
) -> str | None: ) -> str | None:
"""Select active player by playing state, preferred keyword, and continuity.""" """Select active player by playing state, preferred keyword, and continuity."""
if not players: if not players:
@@ -398,7 +399,7 @@ class ActivePlayerSelector:
if len(playing) == 1: if len(playing) == 1:
return playing[0] return playing[0]
preferred = options.preferred_player.lower().strip() preferred = config.general.preferred_player.lower().strip()
candidates = playing if playing else list(players.keys()) candidates = playing if playing else list(players.keys())
if preferred: if preferred:
for name in candidates: for name in candidates:
+20 -34
View File
@@ -7,35 +7,21 @@
import asyncio import asyncio
from dataclasses import asdict from dataclasses import asdict
from typing import Optional, Protocol from typing import Optional
from loguru import logger from loguru import logger
from ..fetchers import FetcherMethodType from ..core import LrcManager
from ..lrc import LRCData from ..lrc import LRCData
from ..models import LyricResult
from ..models import TrackMeta from ..models import TrackMeta
from .control import ControlServer from .control import ControlServer
from .fetcher import LyricFetcher from .fetcher import LyricFetcher
from .options import WatchOptions from ..config import AppConfig
from .view import BaseOutput, LyricView, WatchState from .view import BaseOutput, LyricView, WatchState
from .player import ActivePlayerSelector, PlayerMonitor, PlayerTarget from .player import ActivePlayerSelector, PlayerMonitor, PlayerTarget
from .tracker import PositionTracker 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: class WatchModel:
"""Model layer that owns watch state and lyric timeline representation.""" """Model layer that owns watch state and lyric timeline representation."""
@@ -103,9 +89,9 @@ class WatchViewModel:
class WatchCoordinator: class WatchCoordinator:
"""Application/service orchestration layer for watch runtime.""" """Application/service orchestration layer for watch runtime."""
_manager: FetchManager _manager: LrcManager
_output: BaseOutput _output: BaseOutput
_options: WatchOptions _config: AppConfig
_model: WatchModel _model: WatchModel
_view_model: WatchViewModel _view_model: WatchViewModel
_player_hint: str | None _player_hint: str | None
@@ -120,14 +106,14 @@ class WatchCoordinator:
def __init__( def __init__(
self, self,
manager: FetchManager, manager: LrcManager,
output: BaseOutput, output: BaseOutput,
player_hint: str | None, player_hint: str | None,
options: WatchOptions, config: AppConfig,
) -> None: ) -> None:
self._manager = manager self._manager = manager
self._output = output self._output = output
self._options = options self._config = config
self._model = WatchModel() self._model = WatchModel()
self._view_model = WatchViewModel(self._model) self._view_model = WatchViewModel(self._model)
self._player_hint = player_hint self._player_hint = player_hint
@@ -137,27 +123,27 @@ class WatchCoordinator:
self._target = PlayerTarget( self._target = PlayerTarget(
hint=player_hint, 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( self._player_monitor = PlayerMonitor(
on_players_changed=self._on_player_change, on_players_changed=self._on_player_change,
on_seeked=self._on_seeked, on_seeked=self._on_seeked,
on_playback_status=self._on_playback_status, on_playback_status=self._on_playback_status,
options=self._options, config=self._config,
target=self._target, target=self._target,
) )
self._tracker = PositionTracker( self._tracker = PositionTracker(
poll_position_ms=self._player_monitor.get_position_ms, poll_position_ms=self._player_monitor.get_position_ms,
options=self._options, config=self._config,
on_tick=self._on_tracker_tick, on_tick=self._on_tracker_tick,
) )
self._fetcher = LyricFetcher( self._fetcher = LyricFetcher(
fetch_func=self._fetch_lyrics, fetch_func=self._fetch_lyrics,
on_fetching=self._on_fetching, on_fetching=self._on_fetching,
on_result=self._on_lyrics_update, on_result=self._on_lyrics_update,
options=self._options, config=self._config,
) )
async def run(self) -> bool: async def run(self) -> bool:
@@ -199,7 +185,7 @@ class WatchCoordinator:
async def _calibration_loop(self) -> None: async def _calibration_loop(self) -> None:
"""Periodically refresh full MPRIS snapshot as fallback calibration.""" """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: while True:
await asyncio.sleep(interval) await asyncio.sleep(interval)
try: try:
@@ -234,7 +220,7 @@ class WatchCoordinator:
track, track,
None, None,
False, False,
True, False,
) )
if result and result.lyrics: if result and result.lyrics:
return result.lyrics return result.lyrics
@@ -248,7 +234,7 @@ class WatchCoordinator:
selected = ActivePlayerSelector.select( selected = ActivePlayerSelector.select(
self._player_monitor.players, self._player_monitor.players,
self._model.active_player, self._model.active_player,
self._options, self._config,
) )
self._model.active_player = selected self._model.active_player = selected
@@ -313,8 +299,8 @@ class WatchCoordinator:
self._model.status = "ok" self._model.status = "ok"
elif started_fetch: elif started_fetch:
self._model.status = "fetching" self._model.status = "fetching"
else: elif self._model.status != "fetching":
self._model.status = "paused" self._model.status = "no_lyrics"
self._schedule_emit() self._schedule_emit()
def _on_seeked(self, bus_name: str, position_ms: int) -> None: def _on_seeked(self, bus_name: str, position_ms: int) -> None:
@@ -330,8 +316,8 @@ class WatchCoordinator:
self._model.status = "ok" self._model.status = "ok"
elif started_fetch: elif started_fetch:
self._model.status = "fetching" self._model.status = "fetching"
else: elif self._model.status != "fetching":
self._model.status = "paused" self._model.status = "no_lyrics"
else: else:
self._model.status = "paused" self._model.status = "paused"
self._schedule_emit() self._schedule_emit()
+5 -5
View File
@@ -4,13 +4,13 @@ import asyncio
import time import time
from typing import Awaitable, Callable, Optional from typing import Awaitable, Callable, Optional
from .options import WatchOptions from ..config import AppConfig
class PositionTracker: class PositionTracker:
"""Maintains an estimated playback position from seek/status events plus local clock.""" """Maintains an estimated playback position from seek/status events plus local clock."""
_options: WatchOptions _config: AppConfig
_poll_position_ms: Callable[[str], Awaitable[Optional[int]]] _poll_position_ms: Callable[[str], Awaitable[Optional[int]]]
_active_player: str | None _active_player: str | None
_is_playing: bool _is_playing: bool
@@ -24,11 +24,11 @@ class PositionTracker:
def __init__( def __init__(
self, self,
poll_position_ms: Callable[[str], Awaitable[Optional[int]]], poll_position_ms: Callable[[str], Awaitable[Optional[int]]],
options: WatchOptions, config: AppConfig,
on_tick: Callable[[], None] | None = None, on_tick: Callable[[], None] | None = None,
) -> None: ) -> None:
"""Initialize tracker with position polling callback and runtime options.""" """Initialize tracker with position polling callback and runtime options."""
self._options = options self._config = config
self._poll_position_ms = poll_position_ms self._poll_position_ms = poll_position_ms
self._on_tick = on_tick self._on_tick = on_tick
self._active_player: str | None = None self._active_player: str | None = None
@@ -105,7 +105,7 @@ class PositionTracker:
async def _fast_loop(self) -> None: async def _fast_loop(self) -> None:
"""Advance position by monotonic clock while active player is playing.""" """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: while True:
await asyncio.sleep(interval) await asyncio.sleep(interval)
should_notify = False should_notify = False
View File
-10
View File
@@ -1,13 +1,3 @@
import pytest
from lrx_cli.config import enable_debug from lrx_cli.config import enable_debug
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 import pytest
from lrx_cli.config import load_config
_credentials = load_config().credentials
requires_spotify = pytest.mark.skipif( requires_spotify = pytest.mark.skipif(
not os.environ.get("SPOTIFY_SP_DC"), not _credentials.spotify_sp_dc,
reason="requires SPOTIFY_SP_DC", reason="requires credentials.spotify_sp_dc in config.toml",
) )
requires_qq_music = pytest.mark.skipif( requires_qq_music = pytest.mark.skipif(
not os.environ.get("QQ_MUSIC_API_URL"), not _credentials.qq_music_api_url,
reason="requires QQ_MUSIC_API_URL", reason="requires credentials.qq_music_api_url in config.toml",
) )
requires_musixmatch_token = pytest.mark.skipif( requires_musixmatch_token = pytest.mark.skipif(
not os.environ.get("MUSIXMATCH_USERTOKEN"), not _credentials.musixmatch_usertoken,
reason="requires 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 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.fetchers import FetcherMethodType
from lrx_cli.models import TrackMeta from lrx_cli.models import TrackMeta
from lrx_cli.core import LrcManager
from tests.marks import ( from tests.marks import (
requires_spotify,
requires_qq_music,
requires_musixmatch_token, requires_musixmatch_token,
requires_qq_music,
requires_spotify,
) )
SAMPLE_SPOTIFY_TRACK: TrackMeta = TrackMeta( SAMPLE_SPOTIFY_TRACK: TrackMeta = TrackMeta(
@@ -33,7 +35,14 @@ SAMPLE_SPOTIFY_TRACK_ALBUM_ARTIST_MODIFIED = replace(
@pytest.fixture @pytest.fixture
def lrc_manager(tmp_path: Path) -> LrcManager: 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( def _fetch_and_assert(
@@ -112,7 +121,6 @@ def test_cache_search_fetcher_prefer_better_match(lrc_manager: LrcManager):
], ],
) )
def test_anonymous_remote_fetchers( def test_anonymous_remote_fetchers(
no_credentials,
lrc_manager: LrcManager, lrc_manager: LrcManager,
method: FetcherMethodType, method: FetcherMethodType,
expect_fail: bool, expect_fail: bool,
@@ -122,18 +130,18 @@ def test_anonymous_remote_fetchers(
@pytest.mark.network @pytest.mark.network
@requires_spotify @requires_spotify
def test_spotify_fetcher(lrc_manager: LrcManager): def test_spotify_fetcher(cred_lrc_manager: LrcManager):
_fetch_and_assert(lrc_manager, "spotify") _fetch_and_assert(cred_lrc_manager, "spotify")
@pytest.mark.network @pytest.mark.network
@requires_qq_music @requires_qq_music
def test_qqmusic_fetcher(lrc_manager: LrcManager): def test_qqmusic_fetcher(cred_lrc_manager: LrcManager):
_fetch_and_assert(lrc_manager, "qqmusic") _fetch_and_assert(cred_lrc_manager, "qqmusic")
@pytest.mark.network @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 # These fetchers should be tested in a single test to share the same usertoken
# Otherwise the second may fail due to rate limits # Otherwise the second may fail due to rate limits
_fetch_and_assert(lrc_manager, "musixmatch", expect_fail=False) _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 @pytest.mark.network
@requires_musixmatch_token @requires_musixmatch_token
def test_musixmatch_fetcher(lrc_manager: LrcManager): def test_musixmatch_fetcher(cred_lrc_manager: LrcManager):
_fetch_and_assert(lrc_manager, "musixmatch") _fetch_and_assert(cred_lrc_manager, "musixmatch")
_fetch_and_assert(lrc_manager, "musixmatch-spotify") _fetch_and_assert(cred_lrc_manager, "musixmatch-spotify")
def test_local_fetcher(lrc_manager: LrcManager): 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 fetcher.called
assert result is not None assert result is not None
assert result.status == CacheStatus.SUCCESS_SYNCED 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.view.pipe import PipeOutput
from lrx_cli.watch.player import ActivePlayerSelector, PlayerState, PlayerTarget from lrx_cli.watch.player import ActivePlayerSelector, PlayerState, PlayerTarget
from lrx_cli.watch.fetcher import LyricFetcher 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.tracker import PositionTracker
from lrx_cli.watch.session import WatchCoordinator from lrx_cli.watch.session import WatchCoordinator
TEST_WATCH_OPTIONS = WatchOptions( TEST_CONFIG = AppConfig()
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"),
)
def test_parse_delta_supports_plus_minus_and_reset() -> None: def test_parse_delta_supports_plus_minus_and_reset() -> None:
@@ -64,7 +57,7 @@ def test_active_player_selector_prefers_single_playing() -> None:
), ),
} }
assert ( assert (
ActivePlayerSelector.select(players, None, TEST_WATCH_OPTIONS) ActivePlayerSelector.select(players, None, TEST_CONFIG)
== "org.mpris.MediaPlayer2.bar" == "org.mpris.MediaPlayer2.bar"
) )
@@ -87,7 +80,7 @@ def test_active_player_selector_uses_last_active_when_no_playing() -> None:
ActivePlayerSelector.select( ActivePlayerSelector.select(
players, players,
"org.mpris.MediaPlayer2.bar", "org.mpris.MediaPlayer2.bar",
TEST_WATCH_OPTIONS, TEST_CONFIG,
) )
== "org.mpris.MediaPlayer2.bar" == "org.mpris.MediaPlayer2.bar"
) )
@@ -98,7 +91,7 @@ def test_position_tracker_seeked_calibrates_immediately() -> None:
async def _poll(_bus: str): async def _poll(_bus: str):
return 1200 return 1200
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS) tracker = PositionTracker(_poll, TEST_CONFIG)
await tracker.start() await tracker.start()
await tracker.set_active_player( await tracker.set_active_player(
"org.mpris.MediaPlayer2.foo", "Playing", "track-A" "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): async def _poll(_bus: str):
return 0 return 0
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS) tracker = PositionTracker(_poll, TEST_CONFIG)
await tracker.start() await tracker.start()
await tracker.set_active_player( await tracker.set_active_player(
"org.mpris.MediaPlayer2.foo", "Playing", "track-A" "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): async def _poll(_bus: str):
return 50000 return 50000
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS) tracker = PositionTracker(_poll, TEST_CONFIG)
await tracker.start() await tracker.start()
await tracker.set_active_player( await tracker.set_active_player(
"org.mpris.MediaPlayer2.foo", "Paused", "track-A" "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): async def _poll(_bus: str):
return 42000 return 42000
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS) tracker = PositionTracker(_poll, TEST_CONFIG)
await tracker.start() await tracker.start()
await tracker.set_active_player( await tracker.set_active_player(
"org.mpris.MediaPlayer2.foo", "Paused", "track-A" "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"} return {"ok": True, "offset_ms": self.offset, "lyrics_status": "idle"}
socket_path = tmp_path / "watch.sock" 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() session = _Session()
await server.start(session) await server.start(session) # type: ignore
client = ControlClient(socket_path=socket_path, options=TEST_WATCH_OPTIONS) client = ControlClient(socket_path=socket_path, config=TEST_CONFIG)
r1 = await client._send_async({"cmd": "offset", "delta": 200}) r1 = await client._send_async({"cmd": "offset", "delta": 200})
r2 = await client._send_async({"cmd": "status"}) r2 = await client._send_async({"cmd": "status"})
await server.stop() await server.stop()
@@ -327,23 +320,23 @@ def test_session_fetches_on_resume_playing_without_lyrics() -> None:
async def _on_result(_lyrics) -> None: async def _on_result(_lyrics) -> None:
return None return None
super().__init__(_fetch, _on_fetching, _on_result, TEST_WATCH_OPTIONS) super().__init__(_fetch, _on_fetching, _on_result, TEST_CONFIG)
self.requested = [] self.requested = []
def request(self, track: TrackMeta) -> None: def request(self, track: TrackMeta) -> None:
self.requested.append(track.display_name()) self.requested.append(track.display_name())
session = WatchCoordinator( session = WatchCoordinator(
_Manager(), _Manager(), # type: ignore
_Output(), _Output(),
player_hint=None, player_hint=None,
options=TEST_WATCH_OPTIONS, config=TEST_CONFIG,
) )
fake_fetcher = _Fetcher() fake_fetcher = _Fetcher()
session._fetcher = fake_fetcher session._fetcher = fake_fetcher
session._tracker = PositionTracker( session._tracker = PositionTracker(
lambda _bus: asyncio.sleep(0, result=0), lambda _bus: asyncio.sleep(0, result=0),
TEST_WATCH_OPTIONS, TEST_CONFIG,
) )
bus_name = "org.mpris.MediaPlayer2.spotify" bus_name = "org.mpris.MediaPlayer2.spotify"
@@ -379,14 +372,14 @@ def test_session_emit_state_only_when_lyric_cursor_changes() -> None:
output = _Output() output = _Output()
session = WatchCoordinator( session = WatchCoordinator(
_Manager(), _Manager(), # type: ignore
output, output,
player_hint=None, player_hint=None,
options=TEST_WATCH_OPTIONS, config=TEST_CONFIG,
) )
session._tracker = PositionTracker( session._tracker = PositionTracker(
lambda _bus: asyncio.sleep(0, result=0), lambda _bus: asyncio.sleep(0, result=0),
TEST_WATCH_OPTIONS, TEST_CONFIG,
) )
bus_name = "org.mpris.MediaPlayer2.spotify" bus_name = "org.mpris.MediaPlayer2.spotify"
@@ -429,14 +422,14 @@ def test_session_emits_when_crossing_first_timestamp() -> None:
output = _Output() output = _Output()
session = WatchCoordinator( session = WatchCoordinator(
_Manager(), _Manager(), # type: ignore
output, output,
player_hint=None, player_hint=None,
options=TEST_WATCH_OPTIONS, config=TEST_CONFIG,
) )
session._tracker = PositionTracker( session._tracker = PositionTracker(
lambda _bus: asyncio.sleep(0, result=0), lambda _bus: asyncio.sleep(0, result=0),
TEST_WATCH_OPTIONS, TEST_CONFIG,
) )
bus_name = "org.mpris.MediaPlayer2.spotify" bus_name = "org.mpris.MediaPlayer2.spotify"
Generated
+33 -44
View File
@@ -43,7 +43,7 @@ wheels = [
[[package]] [[package]]
name = "cyclopts" name = "cyclopts"
version = "4.10.1" version = "4.10.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "attrs" }, { name = "attrs" },
@@ -51,9 +51,9 @@ dependencies = [
{ name = "rich" }, { name = "rich" },
{ name = "rich-rst" }, { 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 = [ 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]] [[package]]
@@ -153,7 +153,7 @@ wheels = [
[[package]] [[package]]
name = "lrx-cli" name = "lrx-cli"
version = "0.6.4" version = "0.6.5"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "cyclopts" }, { name = "cyclopts" },
@@ -162,7 +162,6 @@ dependencies = [
{ name = "loguru" }, { name = "loguru" },
{ name = "mutagen" }, { name = "mutagen" },
{ name = "platformdirs" }, { name = "platformdirs" },
{ name = "python-dotenv" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -178,8 +177,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "loguru", specifier = ">=0.7.3" }, { name = "loguru", specifier = ">=0.7.3" },
{ name = "mutagen", specifier = ">=1.47.0" }, { name = "mutagen", specifier = ">=1.47.0" },
{ name = "platformdirs", specifier = ">=4.9.4" }, { name = "platformdirs", specifier = ">=4.9.6" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -229,11 +227,11 @@ wheels = [
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.9.4" version = "4.9.6"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
@@ -247,16 +245,16 @@ wheels = [
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" version = "2.20.0"
source = { registry = "https://pypi.org/simple" } 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 = [ 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]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" version = "9.0.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -265,18 +263,9 @@ dependencies = [
{ name = "pluggy" }, { name = "pluggy" },
{ name = "pygments" }, { 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 = [ 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" }, { 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]]
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" },
] ]
[[package]] [[package]]
@@ -307,27 +296,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.15.8" version = "0.15.10"
source = { registry = "https://pypi.org/simple" } 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 = [ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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]] [[package]]