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:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user