Compare commits

..

7 Commits

Author SHA1 Message Date
Uyanide 7fd98ffcb9 feat: revert c8ccf31 2026-04-10 18:51:42 +02:00
Uyanide cfac95ca03 refactor: add __all__ and more decouple 2026-04-10 17:32:49 +02:00
Uyanide 10bfb6090c chore: preferred_player default to none 2026-04-10 15:49:22 +02:00
Uyanide c8ccf31583 feat: log blacklisted player 2026-04-10 15:45:07 +02:00
Uyanide 6e971941f8 chore: 0.7.7 2026-04-10 15:45:07 +02:00
Uyanide 794f42b42b feat: --player bypasses players_blacklist 2026-04-10 15:37:06 +02:00
Uyanide b18d860aca chore: update requirements.txt 2026-04-10 15:37:06 +02:00
18 changed files with 201 additions and 138 deletions
+2 -2
View File
@@ -111,8 +111,8 @@ optional; all values have defaults. Unknown keys are rejected with an error.
```toml
[general]
preferred_player = "spotify" # preferred MPRIS player when multiple are active
player_blacklist = ["firefox", "zen", "chrome", "chromium", "vivaldi", "edge", "opera", "mpv"]
preferred_player = "" # preferred MPRIS player when multiple are active
player_blacklist = ["firefox", "zen", "chrome", "chromium", "vivaldi", "edge", "opera", "mpv"] # bypassed by --player/-p
http_timeout = 10.0 # seconds
[credentials]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "lrx-cli"
version = "0.7.6"
version = "0.7.9"
description = "Fetch line-synced lyrics for your music player."
readme = "README.md"
requires-python = ">=3.13"
+49
View File
@@ -75,10 +75,18 @@ mutagen==1.47.0 \
--hash=sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99 \
--hash=sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719
# via lrx-cli
nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
# via pyright
packaging==26.0 \
--hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \
--hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529
# via pytest
pastel==0.2.1 \
--hash=sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364 \
--hash=sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d
# via poethepoet
platformdirs==4.9.6 \
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
@@ -87,15 +95,52 @@ pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via pytest
poethepoet==0.44.0 \
--hash=sha256:36d3d834708ed069ac1e4f8ed77915c55265b7b6e01aeb2fe617c9fe9cfd524a \
--hash=sha256:c2667b513621788fb46482e371cdf81c0b04344e0e0bcb7aa8af45f84c2fce7b
pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via
# pytest
# rich
pyright==1.1.408 \
--hash=sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1 \
--hash=sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684
pytest==9.0.3 \
--hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \
--hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6
# via poethepoet
rich==14.3.3 \
--hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \
--hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b
@@ -125,6 +170,10 @@ ruff==0.15.10 \
--hash=sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5 \
--hash=sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07 \
--hash=sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via pyright
win32-setctime==1.2.0 ; sys_platform == 'win32' \
--hash=sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390 \
--hash=sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0
+21
View File
@@ -0,0 +1,21 @@
from .config import AppConfig, GeneralConfig, CredentialConfig, load_config
from .core import LrcManager
from .models import CacheStatus, TrackMeta, LyricResult
from .lrc import LRCData, LyricLine
from .fetchers import FetcherMethodType
from .utils import get_sidecar_path
__all__ = [
"AppConfig",
"GeneralConfig",
"CredentialConfig",
"load_config",
"LrcManager",
"CacheStatus",
"TrackMeta",
"LRCData",
"LyricLine",
"LyricResult",
"FetcherMethodType",
"get_sidecar_path",
]
+1 -1
View File
@@ -24,7 +24,7 @@ from .config import (
SLOT_UNSYNCED,
)
from .models import TrackMeta, LyricResult, CacheStatus
from .ranking import is_positive_status, select_best_positive
from .utils import is_positive_status, select_best_positive
_ALL_SLOTS = (SLOT_SYNCED, SLOT_UNSYNCED)
+2 -2
View File
@@ -23,11 +23,11 @@ from .config import (
load_config,
enable_debug,
)
from .utils import get_sidecar_path
from .models import TrackMeta
from .mpris import get_current_track
from .core import LrcManager
from .fetchers import FetcherMethodType
from .lrc import get_sidecar_path
from .watch import WatchCoordinator
from .watch.control import ControlClient, parse_delta
from .watch.view.pipe import PipeOutput
@@ -71,7 +71,7 @@ def launcher(
str | None,
cyclopts.Parameter(
name=["--player", "-p"],
help="Target a specific MPRIS player using its DBus name or a portion thereof.",
help="Target a specific MPRIS player using its DBus name or a portion thereof. Bypasses player_blacklist.",
),
] = None,
db_path: Annotated[
+1 -1
View File
@@ -69,7 +69,7 @@ MUSIXMATCH_COOLDOWN_MS = 600_000 # 10 minutes
os.makedirs(CACHE_DIR, exist_ok=True)
DEFAULT_PREFERRED_PLAYER = "spotify"
DEFAULT_PREFERRED_PLAYER = ""
DEFAULT_PLAYER_BLACKLIST: tuple[str, ...] = (
"firefox",
"zen",
+1 -1
View File
@@ -28,7 +28,7 @@ from .config import (
)
from .models import TrackMeta, LyricResult, CacheStatus
from .enrichers import create_enrichers, enrich_track
from .ranking import is_better_result, select_best_positive
from .utils import is_better_result, select_best_positive
# Maps CacheStatus to the default TTL used when storing results
+1 -1
View File
@@ -12,7 +12,7 @@ from mutagen._file import File, FileType
from .base import BaseEnricher
from ..models import TrackMeta
from ..lrc import get_audio_path
from ..utils import get_audio_path
class AudioTagEnricher(BaseEnricher):
+1 -1
View File
@@ -12,7 +12,7 @@ from loguru import logger
from .base import BaseEnricher
from ..models import TrackMeta
from ..lrc import get_audio_path
from ..utils import get_audio_path
# Common track-number prefixes: "01 - ", "01. ", "1 - ", etc.
+2 -1
View File
@@ -16,7 +16,8 @@ from mutagen.flac import FLAC
from .base import BaseFetcher, FetchResult
from ..models import CacheStatus, TrackMeta, LyricResult
from ..lrc import get_audio_path, get_sidecar_path, LRCData
from ..lrc import LRCData
from ..utils import get_audio_path, get_sidecar_path
class LocalFetcher(BaseFetcher):
-33
View File
@@ -9,9 +9,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import re
from pathlib import Path
from typing import Optional
from urllib.parse import unquote
from .models import CacheStatus
@@ -465,34 +463,3 @@ class LRCData:
"""
normalized = self.normalize()
return self._serialize_lines(normalized._lines, include_word_sync=False)
def get_audio_path(audio_url: str, ensure_exists: bool = False) -> Optional[Path]:
"""Convert file:// URL to Path, return None if invalid or (if ensure_exists) file doesn't exist."""
if not audio_url.startswith("file://"):
return None
file_path = unquote(audio_url.replace("file://", "", 1))
path = Path(file_path)
if ensure_exists and not path.exists():
return None
return path
def get_sidecar_path(
audio_url: str,
ensure_audio_exists: bool = False,
ensure_exists: bool = False,
extension: str = ".lrc",
) -> Optional[Path]:
"""Given a file:// URL, return the corresponding .lrc sidecar path.
If ensure_audio_exists is True, return None if the audio file does not exist.
If ensure_exists is True, return None if the .lrc file does not exist.
"""
audio_path = get_audio_path(audio_url, ensure_exists=ensure_audio_exists)
if not audio_path:
return None
lrc_path = audio_path.with_suffix(extension)
if ensure_exists and not lrc_path.exists():
return None
return lrc_path
+50 -29
View File
@@ -17,11 +17,8 @@ from .config import DEFAULT_PLAYER_BLACKLIST, DEFAULT_PREFERRED_PLAYER
from .models import TrackMeta
async def _list_mpris_players(
bus: MessageBus,
player_blacklist: tuple[str, ...],
) -> List[str]:
"""List all MPRIS player bus names, excluding blacklisted entries."""
async def _list_mpris_players(bus: MessageBus) -> List[str]:
"""List all MPRIS player bus names without any filtering."""
try:
reply = await bus.call(
Message(
@@ -34,10 +31,7 @@ async def _list_mpris_players(
if not reply or not reply.body:
return []
return [
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)
name for name in reply.body[0] if name.startswith("org.mpris.MediaPlayer2.")
]
except Exception as e:
logger.error(f"Failed to list DBus names: {e}")
@@ -61,6 +55,32 @@ async def _get_playback_status(bus: MessageBus, player_name: str) -> Optional[st
return None
def pick_active_player(
all_names: list[str],
playing: list[str],
preferred: str,
last_active: str | None = None,
) -> str | None:
"""Select the best MPRIS player by play state, preferred keyword, and continuity.
Priority: single playing > preferred keyword among playing > preferred keyword
among all candidates > last active > first candidate.
"""
if not all_names:
return None
if len(playing) == 1:
return playing[0]
candidates = playing if playing else all_names
preferred_lower = preferred.lower().strip()
if preferred_lower:
for name in candidates:
if preferred_lower in name.lower():
return name
if last_active and last_active in all_names:
return last_active
return candidates[0] if candidates else None
async def _select_player(
bus: MessageBus,
specific_player: Optional[str],
@@ -69,38 +89,39 @@ async def _select_player(
) -> Optional[str]:
"""Select the best MPRIS player.
When specific_player is given, filter by name match.
When specific_player is given, it bypasses player_blacklist and filters by name.
Otherwise: prefer the currently playing player. If multiple are playing,
prefer the one matching preferred_player (default: spotify).
"""
players = await _list_mpris_players(bus, player_blacklist)
if not players:
all_names = await _list_mpris_players(bus)
if not all_names:
return None
if specific_player:
players = [p for p in players if specific_player.lower() in p.lower()]
return players[0] if players else None
# --player bypasses player_blacklist so the user can target any player
matched = [p for p in all_names if specific_player.lower() in p.lower()]
return matched[0] if matched else None
# Check playback status for each player
playing = []
for p in players:
# auto-selection: apply blacklist before choosing
# candidates = []
# for p in all_names:
# if any(x.lower() in p.lower() for x in player_blacklist):
# logger.info(f"Excluding blacklisted player: {p}")
# else:
# candidates.append(p)
candidates = [
p
for p in all_names
if not any(x.lower() in p.lower() for x in player_blacklist)
]
playing: list[str] = []
for p in candidates:
status = await _get_playback_status(bus, p)
logger.debug(f"Player {p}: {status}")
if status == "Playing":
playing.append(p)
candidates = playing if playing else players
if len(candidates) == 1:
return candidates[0]
# Multiple candidates: prefer preferred_player
preferred = preferred_player.lower()
if preferred:
for p in candidates:
if preferred in p.lower():
return p
return candidates[0]
return pick_active_player(candidates, playing, preferred_player)
async def _fetch_metadata_dbus(
@@ -1,14 +1,56 @@
"""Shared ranking rules for LyricResult selection.
This module centralizes how positive lyric results are compared so cache/core
and other callers use the same precedence and edge-case handling.
"""
Author: Uyanide pywang0608@foxmail.com
Date: 2026-04-10 17:06:37
Description: Utility functions
"""
from __future__ import annotations
from typing import Optional
from typing import TYPE_CHECKING, Optional
from urllib.parse import unquote
from pathlib import Path
from .models import CacheStatus, LyricResult
from .models import CacheStatus
if TYPE_CHECKING:
from .models import LyricResult
# Paths
def get_audio_path(audio_url: str, ensure_exists: bool = False) -> Optional[Path]:
"""Convert file:// URL to Path, return None if invalid or (if ensure_exists) file doesn't exist."""
if not audio_url.startswith("file://"):
return None
file_path = unquote(audio_url.replace("file://", "", 1))
path = Path(file_path)
if ensure_exists and not path.exists():
return None
return path
def get_sidecar_path(
audio_url: str,
ensure_audio_exists: bool = False,
ensure_exists: bool = False,
extension: str = ".lrc",
) -> Optional[Path]:
"""Given a file:// URL, return the corresponding .lrc sidecar path.
If ensure_audio_exists is True, return None if the audio file does not exist.
If ensure_exists is True, return None if the .lrc file does not exist.
"""
audio_path = get_audio_path(audio_url, ensure_exists=ensure_audio_exists)
if not audio_path:
return None
lrc_path = audio_path.with_suffix(extension)
if ensure_exists and not lrc_path.exists():
return None
return lrc_path
# Ranking
def is_positive_status(status: CacheStatus) -> bool:
+15 -41
View File
@@ -17,6 +17,7 @@ from dbus_next.message import Message
from loguru import logger
from ..models import TrackMeta
from ..mpris import pick_active_player
def _variant_value(item: object) -> object | None:
@@ -40,25 +41,6 @@ class PlayerTarget:
"""Constraint for choosing which players are visible to watch."""
hint: Optional[str] = None
player_blacklist: tuple[str, ...] = ()
def validation_error(self) -> str | None:
"""Return validation message when hint conflicts with blacklist, else None."""
normalized_hint = self.normalized_hint
if not normalized_hint:
return None
for blocked in self.player_blacklist:
normalized_blocked = blocked.strip().lower()
if not normalized_blocked:
continue
if _keyword_match(normalized_hint, normalized_blocked) or _keyword_match(
normalized_blocked, normalized_hint
):
return (
f"Requested player '{self.hint}' is blocked by "
f"PLAYER_BLACKLIST entry '{blocked}'."
)
return None
@property
def normalized_hint(self) -> str:
@@ -103,7 +85,7 @@ class PlayerMonitor:
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._player_blacklist)
self._target = target or PlayerTarget()
self.players: dict[str, PlayerState] = {}
self._bus: MessageBus | None = None
self._props_cache: dict[str, object] = {}
@@ -169,7 +151,11 @@ class PlayerMonitor:
logger.debug(f"Failed to add DBus match rule {rule}: {e}")
async def _list_mpris_players(self) -> list[str]:
"""List visible MPRIS players after applying blacklist and target filter."""
"""List visible MPRIS players after applying target filter and optional blacklist.
The blacklist is skipped when an explicit player hint is active so that
``--player`` can target any player regardless of PLAYER_BLACKLIST.
"""
if not self._bus:
return []
try:
@@ -184,10 +170,15 @@ class PlayerMonitor:
if not reply or not reply.body:
return []
out: list[str] = []
hint_active = bool(self._target.normalized_hint)
for name in reply.body[0]:
if not name.startswith("org.mpris.MediaPlayer2."):
continue
if any(x.lower() in name.lower() for x in self._player_blacklist):
# --player bypasses the blacklist; only filter when no hint is given
if not hint_active and any(
x.lower() in name.lower() for x in self._player_blacklist
):
# logger.info(f"Excluding blacklisted player: {name}")
continue
if not self._target.allows(name):
continue
@@ -406,23 +397,6 @@ class ActivePlayerSelector:
"""Select active player by playing state, preferred keyword, and continuity."""
if not players:
return None
all_names = list(players.keys())
playing = [name for name, st in players.items() if st.status == "Playing"]
if len(playing) == 1:
# unambiguous — only one player is currently playing
return playing[0]
preferred = preferred_player.lower().strip()
# when multiple players are playing, narrow candidates to those; otherwise
# fall back to all known players so a paused preferred player still wins
candidates = playing if playing else list(players.keys())
if preferred:
for name in candidates:
if preferred in name.lower():
return name
# preserve the last selection to avoid jitter when nothing else changes
if last_active and last_active in players:
return last_active
return candidates[0] if candidates else None
return pick_active_player(all_names, playing, preferred_player, last_active)
+1 -9
View File
@@ -129,10 +129,7 @@ class WatchCoordinator:
self._emit_scheduled = False
self._calibration_task = None
self._target = PlayerTarget(
hint=player_hint,
player_blacklist=self._config.general.player_blacklist,
)
self._target = PlayerTarget(hint=player_hint)
self._control = ControlServer(socket_path=config.watch.socket_path)
self._player_monitor = PlayerMonitor(
@@ -156,11 +153,6 @@ class WatchCoordinator:
async def run(self) -> bool:
"""Run watch workflow and return success flag."""
target_issue = self._target.validation_error()
if target_issue:
logger.error(target_issue)
return False
logger.info(
"watch session starting (player filter: {})",
self._player_hint or "<none>",
+4 -8
View File
@@ -41,14 +41,10 @@ def test_player_target_filters_by_case_insensitive_substring() -> None:
assert target.allows("org.mpris.MediaPlayer2.mpd") is False
def test_player_target_reports_blacklisted_hint() -> None:
target = PlayerTarget("spot", player_blacklist=("spotify",))
assert target.validation_error() is not None
def test_player_target_non_blacklisted_hint_is_valid() -> None:
target = PlayerTarget("mpd", player_blacklist=("spotify",))
assert target.validation_error() is None
def test_player_target_hint_allows_regardless_of_blacklist() -> None:
# --player bypasses PLAYER_BLACKLIST; PlayerTarget.allows() reflects the hint only
target = PlayerTarget("spot")
assert target.allows("org.mpris.MediaPlayer2.spotify") is True
# ActivePlayerSelector
Generated
+1 -1
View File
@@ -153,7 +153,7 @@ wheels = [
[[package]]
name = "lrx-cli"
version = "0.7.6"
version = "0.7.9"
source = { editable = "." }
dependencies = [
{ name = "cyclopts" },