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
+9 -6
View File
@@ -10,6 +10,7 @@ from .base import BaseAuthenticator
from .spotify import SpotifyAuthenticator
from .musixmatch import MusixmatchAuthenticator
from .dummy import DummyAuthenticator
from ..config import AppConfig
__all__ = [
"BaseAuthenticator",
@@ -20,11 +21,13 @@ __all__ = [
]
def create_authenticators(cache) -> dict[str, BaseAuthenticator]:
"""Factory function to create authenticators with cache access."""
def create_authenticators(cache, config: AppConfig) -> dict[str, BaseAuthenticator]:
"""Factory function to create authenticators with injected config."""
return {
"dummy": DummyAuthenticator(),
"spotify": SpotifyAuthenticator(cache),
"musixmatch": MusixmatchAuthenticator(cache),
"qqmusic": QQMusicAuthenticator(),
"dummy": DummyAuthenticator(cache, config.credentials, config.general),
"spotify": SpotifyAuthenticator(cache, config.credentials, config.general),
"musixmatch": MusixmatchAuthenticator(
cache, config.credentials, config.general
),
"qqmusic": QQMusicAuthenticator(cache, config.credentials, config.general),
}
+10
View File
@@ -7,10 +7,20 @@ Description: Base class for credential authenticators.
from abc import ABC, abstractmethod
from typing import Optional
from ..cache import CacheEngine
from ..config import CredentialConfig, GeneralConfig
class BaseAuthenticator(ABC):
"""Manages obtaining, caching, and refreshing a credential for one provider."""
def __init__(
self, cache: CacheEngine, credentials: CredentialConfig, general: GeneralConfig
) -> None:
self._cache = cache
self._credentials = credentials
self._general = general
@property
@abstractmethod
def name(self) -> str: ...
+9 -7
View File
@@ -12,7 +12,7 @@ from loguru import logger
from .base import BaseAuthenticator
from ..cache import CacheEngine
from ..config import HTTP_TIMEOUT, MUSIXMATCH_COOLDOWN_MS, credentials
from ..config import CredentialConfig, GeneralConfig, MUSIXMATCH_COOLDOWN_MS
_MUSIXMATCH_TOKEN_URL = "https://apic-desktop.musixmatch.com/ws/1.1/token.get"
@@ -24,8 +24,10 @@ _MXM_BASE_PARAMS = {
class MusixmatchAuthenticator(BaseAuthenticator):
def __init__(self, cache: CacheEngine) -> None:
self._cache = cache
def __init__(
self, cache: CacheEngine, credentials: CredentialConfig, general: GeneralConfig
) -> None:
super().__init__(cache, credentials, general)
self._cached_token: Optional[str] = None
self._cooldown_until_ms: int = 0
@@ -77,7 +79,7 @@ class MusixmatchAuthenticator(BaseAuthenticator):
logger.debug("Musixmatch: fetching anonymous token")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.get(url, headers=_MXM_HEADERS)
resp.raise_for_status()
data = resp.json()
@@ -102,8 +104,8 @@ class MusixmatchAuthenticator(BaseAuthenticator):
async def _get_token(self) -> Optional[str]:
"""Return a valid token: env var > memory > DB > fresh fetch."""
if credentials.MUSIXMATCH_USERTOKEN:
return credentials.MUSIXMATCH_USERTOKEN
if self._credentials.musixmatch_usertoken:
return self._credentials.musixmatch_usertoken
if self._cached_token:
return self._cached_token
@@ -139,7 +141,7 @@ class MusixmatchAuthenticator(BaseAuthenticator):
self._set_cooldown()
return None
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
url = f"{url_base}?{urlencode({**_MXM_BASE_PARAMS, **params, 'usertoken': token})}"
resp = await client.get(url, headers=_MXM_HEADERS)
+8 -5
View File
@@ -7,19 +7,22 @@ Description: QQ Music API authenticator - currently only a proxy.
from typing import Optional
from .base import BaseAuthenticator
from ..config import credentials
from ..cache import CacheEngine
from ..config import CredentialConfig, GeneralConfig
class QQMusicAuthenticator(BaseAuthenticator):
def __init__(self) -> None:
pass
def __init__(
self, cache: CacheEngine, credentials: CredentialConfig, general: GeneralConfig
) -> None:
super().__init__(cache, credentials, general)
@property
def name(self) -> str:
return "qqmusic"
def is_configured(self) -> bool:
return bool(credentials.QQ_MUSIC_API_URL)
return bool(self._credentials.qq_music_api_url)
async def authenticate(self) -> Optional[str]:
return credentials.QQ_MUSIC_API_URL
return self._credentials.qq_music_api_url.rstrip("/") or None
+18 -10
View File
@@ -14,7 +14,7 @@ from loguru import logger
from .base import BaseAuthenticator
from ..cache import CacheEngine
from ..config import HTTP_TIMEOUT, UA_BROWSER, credentials
from ..config import CredentialConfig, GeneralConfig, UA_BROWSER
_SPOTIFY_TOKEN_URL = "https://open.spotify.com/api/token"
_SPOTIFY_SERVER_TIME_URL = "https://open.spotify.com/api/server-time"
@@ -32,8 +32,10 @@ SPOTIFY_BASE_HEADERS = {
class SpotifyAuthenticator(BaseAuthenticator):
def __init__(self, cache: CacheEngine) -> None:
self._cache = cache
def __init__(
self, cache: CacheEngine, credentials: CredentialConfig, general: GeneralConfig
) -> None:
super().__init__(cache, credentials, general)
self._cached_secret: Optional[Tuple[str, int]] = None
self._cached_token: Optional[str] = None
self._token_expires_at: float = 0.0
@@ -43,7 +45,7 @@ class SpotifyAuthenticator(BaseAuthenticator):
return "spotify"
def is_configured(self) -> bool:
return bool(credentials.SPOTIFY_SP_DC)
return bool(self._credentials.spotify_sp_dc)
@staticmethod
def _generate_totp(server_time_s: int, secret: str) -> str:
@@ -82,7 +84,9 @@ class SpotifyAuthenticator(BaseAuthenticator):
async def _get_server_time(self, client: httpx.AsyncClient) -> Optional[int]:
try:
res = await client.get(_SPOTIFY_SERVER_TIME_URL, timeout=HTTP_TIMEOUT)
res = await client.get(
_SPOTIFY_SERVER_TIME_URL, timeout=self._general.http_timeout
)
res.raise_for_status()
data = res.json()
if not isinstance(data, dict) or "serverTime" not in data:
@@ -100,7 +104,9 @@ class SpotifyAuthenticator(BaseAuthenticator):
logger.debug("Spotify: using cached TOTP secret")
return self._cached_secret
try:
res = await client.get(_SPOTIFY_SECRET_URL, timeout=HTTP_TIMEOUT)
res = await client.get(
_SPOTIFY_SECRET_URL, timeout=self._general.http_timeout
)
res.raise_for_status()
data = res.json()
if not isinstance(data, list) or len(data) == 0:
@@ -133,13 +139,13 @@ class SpotifyAuthenticator(BaseAuthenticator):
if db_token and time.time() < self._token_expires_at - 30:
return db_token
if not credentials.SPOTIFY_SP_DC:
logger.error("Spotify: SPOTIFY_SP_DC env var not set — cannot authenticate")
if not self._credentials.spotify_sp_dc:
logger.error("Spotify: spotify_sp_dc not configured — cannot authenticate")
return None
headers = {
"Accept": "*/*",
"Cookie": f"sp_dc={credentials.SPOTIFY_SP_DC}",
"Cookie": f"sp_dc={self._credentials.spotify_sp_dc}",
**SPOTIFY_BASE_HEADERS,
}
@@ -166,7 +172,9 @@ class SpotifyAuthenticator(BaseAuthenticator):
try:
res = await client.get(
_SPOTIFY_TOKEN_URL, params=params, timeout=HTTP_TIMEOUT
_SPOTIFY_TOKEN_URL,
params=params,
timeout=self._general.http_timeout,
)
if res.status_code != 200:
logger.error(f"Spotify: token request returned {res.status_code}")
+39 -32
View File
@@ -17,12 +17,8 @@ from loguru import logger
from .config import (
DB_PATH,
PLAYER_BLACKLIST,
PREFERRED_PLAYER,
WATCH_CALIBRATION_INTERVAL_S,
WATCH_DEBOUNCE_MS,
WATCH_POSITION_TICK_MS,
WATCH_SOCKET_PATH,
AppConfig,
load_config,
enable_debug,
)
from .models import TrackMeta
@@ -32,7 +28,6 @@ from .fetchers import FetcherMethodType
from .lrc import get_sidecar_path
from .watch import WatchCoordinator
from .watch.control import ControlClient, parse_delta
from .watch.options import WatchOptions
from .watch.view.pipe import PipeOutput
@@ -54,23 +49,12 @@ watch_app.command(ctl_app)
# Global state set by the meta launcher
_player: str | None = None
_db_path: str | None = None
_app_config: AppConfig = AppConfig()
# Will be initialized before any command runs, safe to set to None here
manager: LrcManager = None # type: ignore
def _build_watch_options() -> WatchOptions:
"""Build runtime watch options from CLI composition root."""
return WatchOptions(
preferred_player=PREFERRED_PLAYER,
player_blacklist=tuple(PLAYER_BLACKLIST),
debounce_ms=WATCH_DEBOUNCE_MS,
position_tick_ms=WATCH_POSITION_TICK_MS,
calibration_interval_s=WATCH_CALIBRATION_INTERVAL_S,
socket_path=WATCH_SOCKET_PATH,
)
@app.meta.default
def launcher(
*tokens: Annotated[str, cyclopts.Parameter(show=False, allow_leading_hyphen=True)],
@@ -95,13 +79,13 @@ def launcher(
),
] = None,
):
global _player, _db_path
global _player, _db_path, _app_config, manager
if debug:
enable_debug()
_player = player
_db_path = str(Path(db_path).resolve()) if db_path else DB_PATH
global manager
manager = LrcManager(db_path=_db_path)
_app_config = load_config()
manager = LrcManager(db_path=_db_path, config=_app_config)
app(tokens)
@@ -147,7 +131,11 @@ def fetch(
] = False,
):
"""Fetch and print lyrics for the currently playing track."""
track = get_current_track(_player)
track = get_current_track(
_player,
preferred_player=_app_config.general.preferred_player,
player_blacklist=_app_config.general.player_blacklist,
)
if not track:
logger.error("No active playing track found.")
@@ -331,7 +319,11 @@ def export(
] = False,
):
"""Export lyrics of the current track to a .lrc file."""
track = get_current_track(_player)
track = get_current_track(
_player,
preferred_player=_app_config.general.preferred_player,
player_blacklist=_app_config.general.player_blacklist,
)
if not track:
logger.error("No active playing track found.")
sys.exit(1)
@@ -416,13 +408,12 @@ def pipe(
_player or "<none>",
)
output = PipeOutput(before=max(0, before), after=max(0, after))
options = _build_watch_options()
try:
session = WatchCoordinator(
manager,
output,
player_hint=_player,
options=options,
config=_app_config,
)
success = asyncio.run(session.run())
if not success:
@@ -439,7 +430,7 @@ def offset(delta: str) -> None:
logger.error(parse_error or "Invalid offset delta")
sys.exit(1)
response = ControlClient(options=_build_watch_options()).send(
response = ControlClient(config=_app_config).send(
{"cmd": "offset", "delta": parsed_delta}
)
if not response.get("ok"):
@@ -451,7 +442,7 @@ def offset(delta: str) -> None:
@ctl_app.command
def status() -> None:
"""Print current watch session status as JSON."""
response = ControlClient(options=_build_watch_options()).send({"cmd": "status"})
response = ControlClient(config=_app_config).send({"cmd": "status"})
if not response.get("ok"):
logger.error(response.get("error", "Unknown error"))
sys.exit(1)
@@ -480,7 +471,11 @@ def query(
print()
return
track = get_current_track(_player)
track = get_current_track(
_player,
preferred_player=_app_config.general.preferred_player,
player_blacklist=_app_config.general.player_blacklist,
)
if not track:
logger.error("No active playing track found.")
sys.exit(1)
@@ -500,7 +495,11 @@ def clear(
manager.cache.clear_all()
return
track = get_current_track(_player)
track = get_current_track(
_player,
preferred_player=_app_config.general.preferred_player,
player_blacklist=_app_config.general.player_blacklist,
)
if not track:
logger.error("No active playing track found.")
sys.exit(1)
@@ -590,7 +589,11 @@ def confidence(
logger.error("Score must be between 0 and 100.")
sys.exit(1)
track = get_current_track(_player)
track = get_current_track(
_player,
preferred_player=_app_config.general.preferred_player,
player_blacklist=_app_config.general.player_blacklist,
)
if not track:
logger.error("No active playing track found.")
sys.exit(1)
@@ -614,7 +617,11 @@ def insert(
] = None,
):
"""Manually insert lyrics into the cache for the current track."""
track = get_current_track(_player)
track = get_current_track(
_player,
preferred_player=_app_config.general.preferred_player,
player_blacklist=_app_config.general.player_blacklist,
)
if not track:
logger.error("No active playing track found.")
sys.exit(1)
+130 -48
View File
@@ -1,14 +1,18 @@
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-03-25 10:17:56
Description: Global configuration constants and logger setup.
Description: Global configuration constants, typed config dataclasses, and logger setup.
"""
import dataclasses
import os
import sys
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, get_type_hints
from platformdirs import user_cache_dir, user_config_dir
from dotenv import load_dotenv
from loguru import logger
from importlib.metadata import version
@@ -24,13 +28,7 @@ DB_PATH = os.path.join(CACHE_DIR, "cache.db")
SLOT_SYNCED = "SYNCED"
SLOT_UNSYNCED = "UNSYNCED"
# .env loading
_config_env = Path(user_config_dir(APP_NAME, APP_AUTHOR)) / ".env"
load_dotenv(_config_env) # ~/.config/lrx-cli/.env
load_dotenv() # .env in cwd (does NOT override existing vars)
# HTTP
HTTP_TIMEOUT = 10.0
_WATCH_SOCKET_PATH = str(Path(CACHE_DIR) / "watch.sock")
# Cache TTLs (seconds)
TTL_SYNCED = None # never expires
@@ -66,47 +64,131 @@ UA_LRX = f"LRX-CLI {APP_VERSION} (https://github.com/Uyanide/lrx-cli)"
MUSIXMATCH_COOLDOWN_MS = 600_000 # 10 minutes
# Player preference (used when multiple MPRIS players are active)
PREFERRED_PLAYER = os.environ.get("PREFERRED_PLAYER", "spotify")
PLAYER_BLACKLIST = [
s.strip() for s in os.environ.get("PLAYER_BLACKLIST", "").split(",") if s.strip()
]
# Watch mode
WATCH_DEBOUNCE_MS = int(os.environ.get("WATCH_DEBOUNCE_MS", "400"))
WATCH_CALIBRATION_INTERVAL_S = float(
os.environ.get("WATCH_CALIBRATION_INTERVAL_S", "3.0")
)
WATCH_POSITION_TICK_MS = int(os.environ.get("WATCH_POSITION_TICK_MS", "50"))
WATCH_SOCKET_PATH = Path(CACHE_DIR) / "watch.sock"
class _Credentials:
"""Credential config with lazy os.environ reads.
Stable constants live as module-level names above.
Credentials are @property so monkeypatch.setenv / monkeypatch.delenv
affect them without needing to patch each consumer separately.
"""
@property
def SPOTIFY_SP_DC(self) -> str:
return os.environ.get("SPOTIFY_SP_DC", "")
@property
def QQ_MUSIC_API_URL(self) -> str:
return os.environ.get("QQ_MUSIC_API_URL", "").rstrip("/")
@property
def MUSIXMATCH_USERTOKEN(self) -> str:
return os.environ.get("MUSIXMATCH_USERTOKEN", "")
credentials = _Credentials()
os.makedirs(CACHE_DIR, exist_ok=True)
# Logger
DEFAULT_PREFERRED_PLAYER = "spotify"
DEFAULT_PLAYER_BLACKLIST: tuple[str, ...] = (
"firefox",
"zen",
"chrome",
"chromium",
"vivaldi",
"edge",
"opera",
"mpv",
)
@dataclass(frozen=True)
class GeneralConfig:
preferred_player: str = DEFAULT_PREFERRED_PLAYER
player_blacklist: tuple[str, ...] = DEFAULT_PLAYER_BLACKLIST
http_timeout: float = 10.0
@dataclass(frozen=True)
class CredentialConfig:
spotify_sp_dc: str = ""
musixmatch_usertoken: str = ""
qq_music_api_url: str = ""
@dataclass(frozen=True)
class WatchConfig:
debounce_ms: int = 400
calibration_interval_s: float = 3.0
position_tick_ms: int = 50
socket_path: str = field(default_factory=lambda: _WATCH_SOCKET_PATH)
@dataclass(frozen=True)
class AppConfig:
general: GeneralConfig = field(default_factory=GeneralConfig)
credentials: CredentialConfig = field(default_factory=CredentialConfig)
watch: WatchConfig = field(default_factory=WatchConfig)
_CONFIG_PATH = Path(user_config_dir(APP_NAME, APP_AUTHOR)) / "config.toml"
def _coerce(val: Any, hint: Any, section: str, name: str) -> Any:
"""Coerce and validate one TOML value against its declared field type."""
if hint is str:
if not isinstance(val, str):
raise ValueError(
f"[{section}].{name}: expected str, got {type(val).__name__}"
)
return val
if hint is int:
if not isinstance(val, int) or isinstance(val, bool):
raise ValueError(
f"[{section}].{name}: expected int, got {type(val).__name__}"
)
return val
if hint is float:
if isinstance(val, bool):
raise ValueError(f"[{section}].{name}: expected float, got bool")
if isinstance(val, (int, float)):
return float(val)
raise ValueError(
f"[{section}].{name}: expected float, got {type(val).__name__}"
)
origin = getattr(hint, "__origin__", None)
if origin is tuple:
if not isinstance(val, list):
raise ValueError(
f"[{section}].{name}: expected array, got {type(val).__name__}"
)
for i, item in enumerate(val):
if not isinstance(item, str):
raise ValueError(
f"[{section}].{name}[{i}]: expected str, got {type(item).__name__}"
)
return tuple(val)
raise ValueError(f"[{section}].{name}: unsupported field type {hint!r}")
def _parse_section(raw: dict[str, Any], cls: type, section: str) -> Any:
"""Parse one TOML section dict into a frozen dataclass, rejecting unknown keys."""
fields_map = {f.name: f for f in dataclasses.fields(cls)}
hints = get_type_hints(cls)
unknown = set(raw) - set(fields_map)
if unknown:
raise ValueError(
f"Unknown config keys in [{section}]: {', '.join(sorted(unknown))}"
)
kwargs: dict[str, Any] = {}
for name, f in fields_map.items():
if name not in raw:
if f.default is not dataclasses.MISSING:
kwargs[name] = f.default
elif f.default_factory is not dataclasses.MISSING: # type: ignore[misc]
kwargs[name] = f.default_factory()
continue
kwargs[name] = _coerce(raw[name], hints[name], section, name)
return cls(**kwargs)
def load_config(path: Path | None = None) -> AppConfig:
"""Load AppConfig from TOML file; return all-defaults when file is absent."""
resolved = path or _CONFIG_PATH
if not resolved.exists():
return AppConfig()
with open(resolved, "rb") as f:
data = tomllib.load(f)
return AppConfig(
general=_parse_section(data.get("general", {}), GeneralConfig, "general"),
credentials=_parse_section(
data.get("credentials", {}), CredentialConfig, "credentials"
),
watch=_parse_section(data.get("watch", {}), WatchConfig, "watch"),
)
_LOG_FORMAT = (
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
+4 -3
View File
@@ -22,6 +22,7 @@ from .config import (
HIGH_CONFIDENCE,
SLOT_SYNCED,
SLOT_UNSYNCED,
AppConfig,
)
from .models import TrackMeta, LyricResult, CacheStatus
from .enrichers import create_enrichers, enrich_track
@@ -92,10 +93,10 @@ def _has_negative_for_both_slots(cached_rows: list[LyricResult]) -> bool:
class LrcManager:
"""Main entry point for fetching lyrics with caching."""
def __init__(self, db_path: str) -> None:
def __init__(self, db_path: str, config: AppConfig = AppConfig()) -> None:
self.cache = CacheEngine(db_path=db_path)
self.authenticators = create_authenticators(self.cache)
self.fetchers = create_fetchers(self.cache, self.authenticators)
self.authenticators = create_authenticators(self.cache, config)
self.fetchers = create_fetchers(self.cache, self.authenticators, config)
self.enrichers = create_enrichers(self.authenticators)
async def _run_group(
+13 -11
View File
@@ -23,6 +23,7 @@ from ..authenticators import (
QQMusicAuthenticator,
)
from ..cache import CacheEngine
from ..config import AppConfig
from ..models import TrackMeta
FetcherMethodType = Literal[
@@ -52,26 +53,27 @@ _FETCHER_GROUPS: list[list[FetcherMethodType]] = [
def create_fetchers(
cache: CacheEngine,
authenticators: dict[str, BaseAuthenticator],
config: AppConfig,
) -> dict[FetcherMethodType, BaseFetcher]:
"""Instantiate all fetchers. Returns a dict keyed by source name."""
spotify_auth = authenticators["spotify"]
mxm_auth = authenticators["musixmatch"]
qqmusic_auth = authenticators.get("qqmusic")
qqmusic_auth = authenticators["qqmusic"]
assert isinstance(spotify_auth, SpotifyAuthenticator)
assert isinstance(mxm_auth, MusixmatchAuthenticator)
assert isinstance(qqmusic_auth, QQMusicAuthenticator)
fetchers: dict[FetcherMethodType, BaseFetcher] = {
"local": LocalFetcher(),
g = config.general
return {
"local": LocalFetcher(g),
"cache-search": CacheSearchFetcher(cache),
"spotify": SpotifyFetcher(spotify_auth),
"lrclib": LrclibFetcher(),
"musixmatch-spotify": MusixmatchSpotifyFetcher(mxm_auth),
"lrclib-search": LrclibSearchFetcher(),
"netease": NeteaseFetcher(),
"qqmusic": QQMusicFetcher(qqmusic_auth),
"musixmatch": MusixmatchFetcher(mxm_auth),
"spotify": SpotifyFetcher(g, spotify_auth),
"lrclib": LrclibFetcher(g),
"musixmatch-spotify": MusixmatchSpotifyFetcher(g, mxm_auth),
"lrclib-search": LrclibSearchFetcher(g),
"netease": NeteaseFetcher(g),
"qqmusic": QQMusicFetcher(g, qqmusic_auth),
"musixmatch": MusixmatchFetcher(g, mxm_auth),
}
return fetchers
def build_plan(
+8
View File
@@ -8,6 +8,8 @@ from abc import ABC, abstractmethod
from typing import Optional
from dataclasses import dataclass
from ..authenticators.base import BaseAuthenticator
from ..config import GeneralConfig
from ..models import CacheStatus, TrackMeta, LyricResult
@@ -38,6 +40,12 @@ class FetchResult:
class BaseFetcher(ABC):
def __init__(
self, general: GeneralConfig, auth: Optional[BaseAuthenticator] = None
) -> None:
self._general = general
self._auth = auth
@property
@abstractmethod
def source_name(self) -> str:
+1 -2
View File
@@ -13,7 +13,6 @@ from .base import BaseFetcher, FetchResult
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
TTL_UNSYNCED,
TTL_NOT_FOUND,
UA_LRX,
@@ -46,7 +45,7 @@ class LrclibFetcher(BaseFetcher):
logger.info(f"LRCLIB: fetching lyrics for {track.display_name()}")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.get(url, headers={"User-Agent": UA_LRX})
if resp.status_code == 404:
+1 -2
View File
@@ -15,7 +15,6 @@ from .selection import SearchCandidate, select_best
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
TTL_UNSYNCED,
TTL_NOT_FOUND,
UA_LRX,
@@ -73,7 +72,7 @@ class LrclibSearchFetcher(BaseFetcher):
had_error = False
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
async def _query(params: dict[str, str]) -> tuple[list[dict], bool]:
url = f"{_LRCLIB_SEARCH_URL}?{urlencode(params)}"
+14 -9
View File
@@ -18,6 +18,7 @@ from loguru import logger
from .base import BaseFetcher, FetchResult
from .selection import SearchCandidate, select_best
from ..authenticators.musixmatch import MusixmatchAuthenticator
from ..config import GeneralConfig
from ..lrc import LRCData
from ..models import CacheStatus, LyricResult, TrackMeta
@@ -145,22 +146,24 @@ async def _fetch_macro(
class MusixmatchSpotifyFetcher(BaseFetcher):
"""Direct lookup by Spotify track ID — no search, single request."""
def __init__(self, auth: MusixmatchAuthenticator) -> None:
self.auth = auth
_auth: MusixmatchAuthenticator
def __init__(self, general: GeneralConfig, auth: MusixmatchAuthenticator) -> None:
super().__init__(general, auth)
@property
def source_name(self) -> str:
return "musixmatch-spotify"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.trackid) and not self.auth.is_cooldown()
return bool(track.trackid) and not self._auth.is_cooldown()
async def fetch(self, track: TrackMeta, bypass_cache: bool = False) -> FetchResult:
logger.info(f"Musixmatch-Spotify: fetching lyrics for {track.display_name()}")
try:
lrc = await _fetch_macro(
self.auth,
self._auth,
{"track_spotify_id": track.trackid}, # type: ignore[dict-item]
)
except AttributeError:
@@ -191,8 +194,10 @@ class MusixmatchSpotifyFetcher(BaseFetcher):
class MusixmatchFetcher(BaseFetcher):
"""Metadata search + best-candidate lyric fetch."""
def __init__(self, auth: MusixmatchAuthenticator) -> None:
self.auth = auth
_auth: MusixmatchAuthenticator
def __init__(self, general: GeneralConfig, auth: MusixmatchAuthenticator) -> None:
super().__init__(general, auth)
@property
def source_name(self) -> str:
@@ -203,7 +208,7 @@ class MusixmatchFetcher(BaseFetcher):
return "musixmatch"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.title) and not self.auth.is_cooldown()
return bool(track.title) and not self._auth.is_cooldown()
async def _search(self, track: TrackMeta) -> tuple[Optional[int], float]:
"""Search for track metadata. Raises on network/HTTP errors."""
@@ -218,7 +223,7 @@ class MusixmatchFetcher(BaseFetcher):
params["q_album"] = track.album
logger.debug(f"Musixmatch: searching for '{track.display_name()}'")
data = await self.auth.get_json(_MUSIXMATCH_SEARCH_URL, params)
data = await self._auth.get_json(_MUSIXMATCH_SEARCH_URL, params)
if data is None:
return None, 0.0
@@ -270,7 +275,7 @@ class MusixmatchFetcher(BaseFetcher):
return FetchResult.from_not_found()
lrc = await _fetch_macro(
self.auth,
self._auth,
{"commontrack_id": str(commontrack_id)},
)
except AttributeError:
+2 -3
View File
@@ -16,7 +16,6 @@ from .selection import SearchCandidate, select_ranked
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
TTL_NOT_FOUND,
MULTI_CANDIDATE_DELAY_S,
UA_BROWSER,
@@ -49,7 +48,7 @@ class NeteaseFetcher(BaseFetcher):
logger.debug(f"Netease: searching for '{query}' (limit={limit})")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.post(
_NETEASE_SEARCH_URL,
headers=_NETEASE_BASE_HEADERS,
@@ -114,7 +113,7 @@ class NeteaseFetcher(BaseFetcher):
logger.debug(f"Netease: fetching lyrics for song_id={song_id}")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.post(
_NETEASE_LYRIC_URL,
headers=_NETEASE_BASE_HEADERS,
+11 -9
View File
@@ -18,7 +18,7 @@ from .selection import SearchCandidate, select_ranked
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import (
HTTP_TIMEOUT,
GeneralConfig,
TTL_NOT_FOUND,
MULTI_CANDIDATE_DELAY_S,
)
@@ -29,15 +29,17 @@ from ..authenticators import QQMusicAuthenticator
class QQMusicFetcher(BaseFetcher):
def __init__(self, auth: QQMusicAuthenticator) -> None:
self.auth = auth
_auth: QQMusicAuthenticator
def __init__(self, general: GeneralConfig, auth: QQMusicAuthenticator) -> None:
super().__init__(general, auth)
@property
def source_name(self) -> str:
return "qqmusic"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.title) and self.auth.is_configured()
return bool(track.title) and self._auth.is_configured()
async def _search(
self, track: TrackMeta, limit: int = 10
@@ -49,9 +51,9 @@ class QQMusicFetcher(BaseFetcher):
logger.debug(f"QQMusic: searching for '{query}' (limit={limit})")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.get(
f"{await self.auth.authenticate()}{_QQ_MUSIC_API_SEARCH_ENDPOINT}",
f"{await self._auth.authenticate()}{_QQ_MUSIC_API_SEARCH_ENDPOINT}",
params={"keyword": query, "type": "song", "num": limit},
)
resp.raise_for_status()
@@ -106,9 +108,9 @@ class QQMusicFetcher(BaseFetcher):
logger.debug(f"QQMusic: fetching lyrics for mid={mid}")
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
resp = await client.get(
f"{await self.auth.authenticate()}{_QQ_MUSIC_API_LYRIC_ENDPOINT}",
f"{await self._auth.authenticate()}{_QQ_MUSIC_API_LYRIC_ENDPOINT}",
params={"mid": mid},
)
resp.raise_for_status()
@@ -154,7 +156,7 @@ class QQMusicFetcher(BaseFetcher):
return FetchResult.from_network_error()
async def fetch(self, track: TrackMeta, bypass_cache: bool = False) -> FetchResult:
if not self.auth.is_configured():
if not self._auth.is_configured():
logger.debug("QQMusic: skipped — Auth not configured")
return FetchResult()
+8 -6
View File
@@ -11,21 +11,23 @@ from .base import BaseFetcher, FetchResult
from ..authenticators.spotify import SpotifyAuthenticator, SPOTIFY_BASE_HEADERS
from ..models import TrackMeta, LyricResult, CacheStatus
from ..lrc import LRCData
from ..config import HTTP_TIMEOUT, TTL_NOT_FOUND
from ..config import GeneralConfig, TTL_NOT_FOUND
_SPOTIFY_LYRICS_URL = "https://spclient.wg.spotify.com/color-lyrics/v2/track/"
class SpotifyFetcher(BaseFetcher):
def __init__(self, auth: SpotifyAuthenticator) -> None:
self.auth = auth
def __init__(self, general: GeneralConfig, auth: SpotifyAuthenticator) -> None:
super().__init__(general, auth)
_auth: SpotifyAuthenticator
@property
def source_name(self) -> str:
return "spotify"
def is_available(self, track: TrackMeta) -> bool:
return bool(track.trackid) and self.auth.is_configured()
return bool(track.trackid) and self._auth.is_configured()
@staticmethod
def _format_lrc_line(start_ms: int, words: str) -> str:
@@ -52,7 +54,7 @@ class SpotifyFetcher(BaseFetcher):
logger.info(f"Spotify: fetching lyrics for trackid={track.trackid}")
token = await self.auth.authenticate()
token = await self._auth.authenticate()
if not token:
logger.error("Spotify: cannot fetch lyrics without a token")
return FetchResult.from_network_error()
@@ -65,7 +67,7 @@ class SpotifyFetcher(BaseFetcher):
}
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
async with httpx.AsyncClient(timeout=self._general.http_timeout) as client:
res = await client.get(url, headers=headers)
if res.status_code == 404:
+32 -13
View File
@@ -8,14 +8,17 @@ import asyncio
from dbus_next.aio.message_bus import MessageBus
from dbus_next.constants import BusType
from dbus_next.message import Message
from lrx_cli.config import DEFAULT_PLAYER_BLACKLIST, DEFAULT_PREFERRED_PLAYER
from lrx_cli.models import TrackMeta
from lrx_cli.config import PREFERRED_PLAYER
from loguru import logger
from typing import Optional, List, Any
async def _list_mpris_players(bus: MessageBus) -> List[str]:
"""List all MPRIS player bus names."""
async def _list_mpris_players(
bus: MessageBus,
player_blacklist: tuple[str, ...],
) -> List[str]:
"""List all MPRIS player bus names, excluding blacklisted entries."""
try:
reply = await bus.call(
Message(
@@ -28,7 +31,10 @@ async def _list_mpris_players(bus: MessageBus) -> List[str]:
if not reply or not reply.body:
return []
return [
name for name in reply.body[0] if name.startswith("org.mpris.MediaPlayer2.")
name
for name in reply.body[0]
if name.startswith("org.mpris.MediaPlayer2.")
and not any(x.lower() in name.lower() for x in player_blacklist)
]
except Exception as e:
logger.error(f"Failed to list DBus names: {e}")
@@ -53,15 +59,18 @@ async def _get_playback_status(bus: MessageBus, player_name: str) -> Optional[st
async def _select_player(
bus: MessageBus, specific_player: Optional[str] = None
bus: MessageBus,
specific_player: Optional[str],
preferred_player: str,
player_blacklist: tuple[str, ...],
) -> Optional[str]:
"""Select the best MPRIS player.
When specific_player is given, filter by name match.
Otherwise: prefer the currently playing player. If multiple are playing,
prefer the one matching PREFERRED_PLAYER env var (default: spotify).
prefer the one matching preferred_player (default: spotify).
"""
players = await _list_mpris_players(bus)
players = await _list_mpris_players(bus, player_blacklist)
if not players:
return None
@@ -82,8 +91,8 @@ async def _select_player(
if len(candidates) == 1:
return candidates[0]
# Multiple candidates: prefer PREFERRED_PLAYER
preferred = PREFERRED_PLAYER.lower()
# Multiple candidates: prefer preferred_player
preferred = preferred_player.lower()
if preferred:
for p in candidates:
if preferred in p.lower():
@@ -92,7 +101,9 @@ async def _select_player(
async def _fetch_metadata_dbus(
specific_player: Optional[str] = None,
specific_player: Optional[str],
preferred_player: str,
player_blacklist: tuple[str, ...],
) -> Optional[TrackMeta]:
bus = None
try:
@@ -102,7 +113,9 @@ async def _fetch_metadata_dbus(
return None
try:
player_name = await _select_player(bus, specific_player)
player_name = await _select_player(
bus, specific_player, preferred_player, player_blacklist
)
if not player_name:
logger.debug(
f"No active MPRIS players found via DBus{' for ' + specific_player if specific_player else ''}."
@@ -182,9 +195,15 @@ async def _fetch_metadata_dbus(
bus.disconnect()
def get_current_track(player_name: Optional[str] = None) -> Optional[TrackMeta]:
def get_current_track(
player_name: Optional[str] = None,
preferred_player: str = DEFAULT_PREFERRED_PLAYER,
player_blacklist: tuple[str, ...] = DEFAULT_PLAYER_BLACKLIST,
) -> Optional[TrackMeta]:
try:
return asyncio.run(_fetch_metadata_dbus(player_name))
return asyncio.run(
_fetch_metadata_dbus(player_name, preferred_player, player_blacklist)
)
except Exception as e:
logger.error(f"DBus async loop failed: {e}")
return None
+15 -35
View File
@@ -3,49 +3,32 @@
import asyncio
import json
from pathlib import Path
from typing import Protocol, TypeAlias
from typing import TYPE_CHECKING
from loguru import logger
from .options import WatchOptions
from ..config import AppConfig
JSONPrimitive: TypeAlias = str | int | float | bool | None
JSONValue: TypeAlias = JSONPrimitive | dict[str, "JSONValue"] | list["JSONValue"]
JSONDict: TypeAlias = dict[str, JSONValue]
class ControlSession(Protocol):
"""Session protocol used by control channel handlers."""
def handle_offset(self, delta: int) -> JSONDict:
"""Apply offset delta and return JSON response payload."""
...
def handle_status(self) -> JSONDict:
"""Return current session status payload."""
...
if TYPE_CHECKING:
from .session import WatchCoordinator
class ControlServer:
"""Control server that handles offset/status commands over a Unix socket."""
_options: WatchOptions
_socket_path: Path
_server: asyncio.AbstractServer | None
def __init__(
self,
options: WatchOptions,
config: AppConfig,
socket_path: Path | None = None,
) -> None:
"""Initialize control server with explicit socket path or runtime options."""
self._options = options
resolved_socket_path = socket_path or self._options.socket_path
self._socket_path: Path = resolved_socket_path
"""Initialize control server with socket path from config or explicit override."""
self._socket_path: Path = socket_path or Path(config.watch.socket_path)
self._server: asyncio.AbstractServer | None = None
async def start(self, session: ControlSession) -> bool:
async def start(self, session: "WatchCoordinator") -> bool:
"""Start listening for control requests and bind session handlers."""
if not await self._prepare_socket_path():
return False
@@ -90,12 +73,12 @@ class ControlServer:
async def _handle(
self,
session: ControlSession,
session: "WatchCoordinator",
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter,
) -> None:
"""Handle one control request and send JSON response."""
resp: JSONDict = {"ok": False, "error": "internal error"}
resp: dict[str, object] = {"ok": False, "error": "internal error"}
try:
line = await reader.readline()
if not line:
@@ -122,20 +105,17 @@ class ControlServer:
class ControlClient:
"""Control client used by CLI commands to talk to active watch session."""
_options: WatchOptions
_socket_path: Path
def __init__(
self,
options: WatchOptions,
config: AppConfig,
socket_path: Path | None = None,
) -> None:
"""Initialize control client with explicit socket path or runtime options."""
self._options = options
resolved_socket_path = socket_path or self._options.socket_path
self._socket_path: Path = resolved_socket_path
"""Initialize control client with socket path from config or explicit override."""
self._socket_path: Path = socket_path or Path(config.watch.socket_path)
async def _send_async(self, cmd: JSONDict) -> JSONDict:
async def _send_async(self, cmd: dict[str, object]) -> dict[str, object]:
"""Send one JSON command to control server and return JSON response."""
if not self._socket_path.exists():
return {"ok": False, "error": "No watch session running."}
@@ -154,7 +134,7 @@ class ControlClient:
return {"ok": False, "error": "Empty response."}
return json.loads(line.decode("utf-8"))
def send(self, cmd: JSONDict) -> JSONDict:
def send(self, cmd: dict[str, object]) -> dict[str, object]:
"""Synchronous wrapper around async control request."""
return asyncio.run(self._send_async(cmd))
+5 -5
View File
@@ -3,15 +3,15 @@
import asyncio
from typing import Awaitable, Callable, Optional
from ..config import AppConfig
from ..lrc import LRCData
from ..models import TrackMeta
from .options import WatchOptions
class LyricFetcher:
"""Debounces track updates and runs at most one lyric fetch task at a time."""
_options: WatchOptions
_config: AppConfig
_fetch_func: Callable[[TrackMeta], Awaitable[Optional[LRCData]]]
_on_fetching: Callable[[], Awaitable[None] | None]
_on_result: Callable[[Optional[LRCData]], Awaitable[None] | None]
@@ -24,10 +24,10 @@ class LyricFetcher:
fetch_func: Callable[[TrackMeta], Awaitable[Optional[LRCData]]],
on_fetching: Callable[[], Awaitable[None] | None],
on_result: Callable[[Optional[LRCData]], Awaitable[None] | None],
options: WatchOptions,
config: AppConfig,
) -> None:
"""Initialize fetch callbacks and runtime options."""
self._options = options
self._config = config
self._fetch_func = fetch_func
self._on_fetching = on_fetching
self._on_result = on_result
@@ -56,7 +56,7 @@ class LyricFetcher:
async def _debounce_then_fetch(self) -> None:
"""Wait debounce window then start a fresh fetch task for latest pending track."""
await asyncio.sleep(self._options.debounce_ms / 1000.0)
await asyncio.sleep(self._config.watch.debounce_ms / 1000.0)
track = self._pending_track
if track is None:
return
-16
View File
@@ -1,16 +0,0 @@
"""Watch runtime options passed from CLI composition root."""
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class WatchOptions:
"""Runtime settings used by watch components."""
preferred_player: str
player_blacklist: tuple[str, ...]
debounce_ms: int
position_tick_ms: int
calibration_interval_s: float
socket_path: Path
+9 -8
View File
@@ -9,8 +9,8 @@ from dbus_next.constants import BusType
from dbus_next.message import Message
from loguru import logger
from ..config import AppConfig
from ..models import TrackMeta
from .options import WatchOptions
def _variant_value(item: object) -> object | None:
@@ -75,7 +75,7 @@ def _keyword_match(text: str, keyword: str) -> bool:
class PlayerMonitor:
"""Tracks MPRIS players and forwards signal-driven state updates to session callbacks."""
_options: WatchOptions
_config: AppConfig
_on_players_changed: Callable[[], None]
_on_seeked: Callable[[str, int], None]
_on_playback_status: Callable[[str, str], None]
@@ -89,16 +89,16 @@ class PlayerMonitor:
on_players_changed: Callable[[], None],
on_seeked: Callable[[str, int], None],
on_playback_status: Callable[[str, str], None],
options: WatchOptions,
config: AppConfig,
target: Optional[PlayerTarget] = None,
) -> None:
"""Initialize monitor callbacks, runtime options, and player target filter."""
self._options = options
self._config = config
self._on_players_changed = on_players_changed
self._on_seeked = on_seeked
self._on_playback_status = on_playback_status
self._target = target or PlayerTarget(
player_blacklist=self._options.player_blacklist
player_blacklist=self._config.general.player_blacklist
)
self.players: dict[str, PlayerState] = {}
self._bus: MessageBus | None = None
@@ -184,7 +184,8 @@ class PlayerMonitor:
if not name.startswith("org.mpris.MediaPlayer2."):
continue
if any(
x.lower() in name.lower() for x in self._options.player_blacklist
x.lower() in name.lower()
for x in self._config.general.player_blacklist
):
continue
if not self._target.allows(name):
@@ -388,7 +389,7 @@ class ActivePlayerSelector:
def select(
players: dict[str, PlayerState],
last_active: str | None,
options: WatchOptions,
config: AppConfig,
) -> str | None:
"""Select active player by playing state, preferred keyword, and continuity."""
if not players:
@@ -398,7 +399,7 @@ class ActivePlayerSelector:
if len(playing) == 1:
return playing[0]
preferred = options.preferred_player.lower().strip()
preferred = config.general.preferred_player.lower().strip()
candidates = playing if playing else list(players.keys())
if preferred:
for name in candidates:
+20 -34
View File
@@ -7,35 +7,21 @@
import asyncio
from dataclasses import asdict
from typing import Optional, Protocol
from typing import Optional
from loguru import logger
from ..fetchers import FetcherMethodType
from ..core import LrcManager
from ..lrc import LRCData
from ..models import LyricResult
from ..models import TrackMeta
from .control import ControlServer
from .fetcher import LyricFetcher
from .options import WatchOptions
from ..config import AppConfig
from .view import BaseOutput, LyricView, WatchState
from .player import ActivePlayerSelector, PlayerMonitor, PlayerTarget
from .tracker import PositionTracker
class FetchManager(Protocol):
"""Protocol for lyric fetch manager consumed by watch session."""
def fetch_for_track(
self,
track: TrackMeta,
force_method: FetcherMethodType | None = None,
bypass_cache: bool = False,
allow_unsynced: bool = False,
) -> Optional[LyricResult]:
"""Fetch lyrics for one track."""
class WatchModel:
"""Model layer that owns watch state and lyric timeline representation."""
@@ -103,9 +89,9 @@ class WatchViewModel:
class WatchCoordinator:
"""Application/service orchestration layer for watch runtime."""
_manager: FetchManager
_manager: LrcManager
_output: BaseOutput
_options: WatchOptions
_config: AppConfig
_model: WatchModel
_view_model: WatchViewModel
_player_hint: str | None
@@ -120,14 +106,14 @@ class WatchCoordinator:
def __init__(
self,
manager: FetchManager,
manager: LrcManager,
output: BaseOutput,
player_hint: str | None,
options: WatchOptions,
config: AppConfig,
) -> None:
self._manager = manager
self._output = output
self._options = options
self._config = config
self._model = WatchModel()
self._view_model = WatchViewModel(self._model)
self._player_hint = player_hint
@@ -137,27 +123,27 @@ class WatchCoordinator:
self._target = PlayerTarget(
hint=player_hint,
player_blacklist=self._options.player_blacklist,
player_blacklist=self._config.general.player_blacklist,
)
self._control = ControlServer(options=self._options)
self._control = ControlServer(config=self._config)
self._player_monitor = PlayerMonitor(
on_players_changed=self._on_player_change,
on_seeked=self._on_seeked,
on_playback_status=self._on_playback_status,
options=self._options,
config=self._config,
target=self._target,
)
self._tracker = PositionTracker(
poll_position_ms=self._player_monitor.get_position_ms,
options=self._options,
config=self._config,
on_tick=self._on_tracker_tick,
)
self._fetcher = LyricFetcher(
fetch_func=self._fetch_lyrics,
on_fetching=self._on_fetching,
on_result=self._on_lyrics_update,
options=self._options,
config=self._config,
)
async def run(self) -> bool:
@@ -199,7 +185,7 @@ class WatchCoordinator:
async def _calibration_loop(self) -> None:
"""Periodically refresh full MPRIS snapshot as fallback calibration."""
interval = max(0.1, self._options.calibration_interval_s)
interval = max(0.1, self._config.watch.calibration_interval_s)
while True:
await asyncio.sleep(interval)
try:
@@ -234,7 +220,7 @@ class WatchCoordinator:
track,
None,
False,
True,
False,
)
if result and result.lyrics:
return result.lyrics
@@ -248,7 +234,7 @@ class WatchCoordinator:
selected = ActivePlayerSelector.select(
self._player_monitor.players,
self._model.active_player,
self._options,
self._config,
)
self._model.active_player = selected
@@ -313,8 +299,8 @@ class WatchCoordinator:
self._model.status = "ok"
elif started_fetch:
self._model.status = "fetching"
else:
self._model.status = "paused"
elif self._model.status != "fetching":
self._model.status = "no_lyrics"
self._schedule_emit()
def _on_seeked(self, bus_name: str, position_ms: int) -> None:
@@ -330,8 +316,8 @@ class WatchCoordinator:
self._model.status = "ok"
elif started_fetch:
self._model.status = "fetching"
else:
self._model.status = "paused"
elif self._model.status != "fetching":
self._model.status = "no_lyrics"
else:
self._model.status = "paused"
self._schedule_emit()
+5 -5
View File
@@ -4,13 +4,13 @@ import asyncio
import time
from typing import Awaitable, Callable, Optional
from .options import WatchOptions
from ..config import AppConfig
class PositionTracker:
"""Maintains an estimated playback position from seek/status events plus local clock."""
_options: WatchOptions
_config: AppConfig
_poll_position_ms: Callable[[str], Awaitable[Optional[int]]]
_active_player: str | None
_is_playing: bool
@@ -24,11 +24,11 @@ class PositionTracker:
def __init__(
self,
poll_position_ms: Callable[[str], Awaitable[Optional[int]]],
options: WatchOptions,
config: AppConfig,
on_tick: Callable[[], None] | None = None,
) -> None:
"""Initialize tracker with position polling callback and runtime options."""
self._options = options
self._config = config
self._poll_position_ms = poll_position_ms
self._on_tick = on_tick
self._active_player: str | None = None
@@ -105,7 +105,7 @@ class PositionTracker:
async def _fast_loop(self) -> None:
"""Advance position by monotonic clock while active player is playing."""
interval = self._options.position_tick_ms / 1000.0
interval = self._config.watch.position_tick_ms / 1000.0
while True:
await asyncio.sleep(interval)
should_notify = False