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 ```toml
[general] [general]
preferred_player = "spotify" # preferred MPRIS player when multiple are active preferred_player = "" # preferred MPRIS player when multiple are active
player_blacklist = ["firefox", "zen", "chrome", "chromium", "vivaldi", "edge", "opera", "mpv"] player_blacklist = ["firefox", "zen", "chrome", "chromium", "vivaldi", "edge", "opera", "mpv"] # bypassed by --player/-p
http_timeout = 10.0 # seconds http_timeout = 10.0 # seconds
[credentials] [credentials]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "lrx-cli" name = "lrx-cli"
version = "0.7.6" version = "0.7.9"
description = "Fetch line-synced lyrics for your music player." description = "Fetch line-synced lyrics for your music player."
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
+49
View File
@@ -75,10 +75,18 @@ mutagen==1.47.0 \
--hash=sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99 \ --hash=sha256:719fadef0a978c31b4cf3c956261b3c58b6948b32023078a2117b1de09f0fc99 \
--hash=sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719 --hash=sha256:edd96f50c5907a9539d8e5bba7245f62c9f520aef333d13392a79a4f70aca719
# via lrx-cli # via lrx-cli
nodeenv==1.10.0 \
--hash=sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827 \
--hash=sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb
# via pyright
packaging==26.0 \ packaging==26.0 \
--hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \ --hash=sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4 \
--hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529 --hash=sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529
# via pytest # via pytest
pastel==0.2.1 \
--hash=sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364 \
--hash=sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d
# via poethepoet
platformdirs==4.9.6 \ platformdirs==4.9.6 \
--hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \ --hash=sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a \
--hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917 --hash=sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917
@@ -87,15 +95,52 @@ pluggy==1.6.0 \
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
# via pytest # via pytest
poethepoet==0.44.0 \
--hash=sha256:36d3d834708ed069ac1e4f8ed77915c55265b7b6e01aeb2fe617c9fe9cfd524a \
--hash=sha256:c2667b513621788fb46482e371cdf81c0b04344e0e0bcb7aa8af45f84c2fce7b
pygments==2.20.0 \ pygments==2.20.0 \
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
# via # via
# pytest # pytest
# rich # rich
pyright==1.1.408 \
--hash=sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1 \
--hash=sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684
pytest==9.0.3 \ pytest==9.0.3 \
--hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \ --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \
--hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c --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 \ rich==14.3.3 \
--hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \ --hash=sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d \
--hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b --hash=sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b
@@ -125,6 +170,10 @@ ruff==0.15.10 \
--hash=sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5 \ --hash=sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5 \
--hash=sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07 \ --hash=sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07 \
--hash=sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f --hash=sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via pyright
win32-setctime==1.2.0 ; sys_platform == 'win32' \ win32-setctime==1.2.0 ; sys_platform == 'win32' \
--hash=sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390 \ --hash=sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390 \
--hash=sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0 --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, SLOT_UNSYNCED,
) )
from .models import TrackMeta, LyricResult, CacheStatus 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) _ALL_SLOTS = (SLOT_SYNCED, SLOT_UNSYNCED)
+2 -2
View File
@@ -23,11 +23,11 @@ from .config import (
load_config, load_config,
enable_debug, enable_debug,
) )
from .utils import get_sidecar_path
from .models import TrackMeta from .models import TrackMeta
from .mpris import get_current_track from .mpris import get_current_track
from .core import LrcManager from .core import LrcManager
from .fetchers import FetcherMethodType from .fetchers import FetcherMethodType
from .lrc import get_sidecar_path
from .watch import WatchCoordinator from .watch import WatchCoordinator
from .watch.control import ControlClient, parse_delta from .watch.control import ControlClient, parse_delta
from .watch.view.pipe import PipeOutput from .watch.view.pipe import PipeOutput
@@ -71,7 +71,7 @@ def launcher(
str | None, str | None,
cyclopts.Parameter( cyclopts.Parameter(
name=["--player", "-p"], 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, ] = None,
db_path: Annotated[ 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) os.makedirs(CACHE_DIR, exist_ok=True)
DEFAULT_PREFERRED_PLAYER = "spotify" DEFAULT_PREFERRED_PLAYER = ""
DEFAULT_PLAYER_BLACKLIST: tuple[str, ...] = ( DEFAULT_PLAYER_BLACKLIST: tuple[str, ...] = (
"firefox", "firefox",
"zen", "zen",
+1 -1
View File
@@ -28,7 +28,7 @@ from .config import (
) )
from .models import TrackMeta, LyricResult, CacheStatus from .models import TrackMeta, LyricResult, CacheStatus
from .enrichers import create_enrichers, enrich_track from .enrichers import create_enrichers, enrich_track
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 # 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 .base import BaseEnricher
from ..models import TrackMeta from ..models import TrackMeta
from ..lrc import get_audio_path from ..utils import get_audio_path
class AudioTagEnricher(BaseEnricher): class AudioTagEnricher(BaseEnricher):
+1 -1
View File
@@ -12,7 +12,7 @@ from loguru import logger
from .base import BaseEnricher from .base import BaseEnricher
from ..models import TrackMeta from ..models import TrackMeta
from ..lrc import get_audio_path from ..utils import get_audio_path
# Common track-number prefixes: "01 - ", "01. ", "1 - ", etc. # 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 .base import BaseFetcher, FetchResult
from ..models import CacheStatus, TrackMeta, LyricResult 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): class LocalFetcher(BaseFetcher):
-33
View File
@@ -9,9 +9,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
import re import re
from pathlib import Path
from typing import Optional from typing import Optional
from urllib.parse import unquote
from .models import CacheStatus from .models import CacheStatus
@@ -465,34 +463,3 @@ class LRCData:
""" """
normalized = self.normalize() normalized = self.normalize()
return self._serialize_lines(normalized._lines, include_word_sync=False) 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 from .models import TrackMeta
async def _list_mpris_players( async def _list_mpris_players(bus: MessageBus) -> List[str]:
bus: MessageBus, """List all MPRIS player bus names without any filtering."""
player_blacklist: tuple[str, ...],
) -> List[str]:
"""List all MPRIS player bus names, excluding blacklisted entries."""
try: try:
reply = await bus.call( reply = await bus.call(
Message( Message(
@@ -34,10 +31,7 @@ async def _list_mpris_players(
if not reply or not reply.body: if not reply or not reply.body:
return [] return []
return [ return [
name name for name in reply.body[0] if name.startswith("org.mpris.MediaPlayer2.")
for name in reply.body[0]
if name.startswith("org.mpris.MediaPlayer2.")
and not any(x.lower() in name.lower() for x in player_blacklist)
] ]
except Exception as e: except Exception as e:
logger.error(f"Failed to list DBus names: {e}") logger.error(f"Failed to list DBus names: {e}")
@@ -61,6 +55,32 @@ async def _get_playback_status(bus: MessageBus, player_name: str) -> Optional[st
return None 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( async def _select_player(
bus: MessageBus, bus: MessageBus,
specific_player: Optional[str], specific_player: Optional[str],
@@ -69,38 +89,39 @@ async def _select_player(
) -> Optional[str]: ) -> Optional[str]:
"""Select the best MPRIS player. """Select the best MPRIS player.
When specific_player is given, filter by name match. When specific_player is given, it bypasses player_blacklist and filters by name.
Otherwise: prefer the currently playing player. If multiple are playing, Otherwise: prefer the currently playing player. If multiple are playing,
prefer the one matching preferred_player (default: spotify). prefer the one matching preferred_player (default: spotify).
""" """
players = await _list_mpris_players(bus, player_blacklist) all_names = await _list_mpris_players(bus)
if not players: if not all_names:
return None return None
if specific_player: if specific_player:
players = [p for p in players if specific_player.lower() in p.lower()] # --player bypasses player_blacklist so the user can target any player
return players[0] if players else None 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 # auto-selection: apply blacklist before choosing
playing = [] # candidates = []
for p in players: # 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) status = await _get_playback_status(bus, p)
logger.debug(f"Player {p}: {status}") logger.debug(f"Player {p}: {status}")
if status == "Playing": if status == "Playing":
playing.append(p) playing.append(p)
candidates = playing if playing else players return pick_active_player(candidates, playing, preferred_player)
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]
async def _fetch_metadata_dbus( async def _fetch_metadata_dbus(
@@ -1,14 +1,56 @@
"""Shared ranking rules for LyricResult selection. """
Author: Uyanide pywang0608@foxmail.com
This module centralizes how positive lyric results are compared so cache/core Date: 2026-04-10 17:06:37
and other callers use the same precedence and edge-case handling. Description: Utility functions
""" """
from __future__ import annotations 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: 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 loguru import logger
from ..models import TrackMeta from ..models import TrackMeta
from ..mpris import pick_active_player
def _variant_value(item: object) -> object | None: def _variant_value(item: object) -> object | None:
@@ -40,25 +41,6 @@ class PlayerTarget:
"""Constraint for choosing which players are visible to watch.""" """Constraint for choosing which players are visible to watch."""
hint: Optional[str] = None 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 @property
def normalized_hint(self) -> str: def normalized_hint(self) -> str:
@@ -103,7 +85,7 @@ class PlayerMonitor:
self._on_players_changed = on_players_changed self._on_players_changed = on_players_changed
self._on_seeked = on_seeked self._on_seeked = on_seeked
self._on_playback_status = on_playback_status self._on_playback_status = on_playback_status
self._target = target or PlayerTarget(player_blacklist=self._player_blacklist) self._target = target or PlayerTarget()
self.players: dict[str, PlayerState] = {} self.players: dict[str, PlayerState] = {}
self._bus: MessageBus | None = None self._bus: MessageBus | None = None
self._props_cache: dict[str, object] = {} self._props_cache: dict[str, object] = {}
@@ -169,7 +151,11 @@ class PlayerMonitor:
logger.debug(f"Failed to add DBus match rule {rule}: {e}") logger.debug(f"Failed to add DBus match rule {rule}: {e}")
async def _list_mpris_players(self) -> list[str]: 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: if not self._bus:
return [] return []
try: try:
@@ -184,10 +170,15 @@ class PlayerMonitor:
if not reply or not reply.body: if not reply or not reply.body:
return [] return []
out: list[str] = [] out: list[str] = []
hint_active = bool(self._target.normalized_hint)
for name in reply.body[0]: for name in reply.body[0]:
if not name.startswith("org.mpris.MediaPlayer2."): if not name.startswith("org.mpris.MediaPlayer2."):
continue 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 continue
if not self._target.allows(name): if not self._target.allows(name):
continue continue
@@ -406,23 +397,6 @@ class ActivePlayerSelector:
"""Select active player by playing state, preferred keyword, and continuity.""" """Select active player by playing state, preferred keyword, and continuity."""
if not players: if not players:
return None return None
all_names = list(players.keys())
playing = [name for name, st in players.items() if st.status == "Playing"] playing = [name for name, st in players.items() if st.status == "Playing"]
if len(playing) == 1: return pick_active_player(all_names, playing, preferred_player, last_active)
# 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
+1 -9
View File
@@ -129,10 +129,7 @@ class WatchCoordinator:
self._emit_scheduled = False self._emit_scheduled = False
self._calibration_task = None self._calibration_task = None
self._target = PlayerTarget( self._target = PlayerTarget(hint=player_hint)
hint=player_hint,
player_blacklist=self._config.general.player_blacklist,
)
self._control = ControlServer(socket_path=config.watch.socket_path) self._control = ControlServer(socket_path=config.watch.socket_path)
self._player_monitor = PlayerMonitor( self._player_monitor = PlayerMonitor(
@@ -156,11 +153,6 @@ class WatchCoordinator:
async def run(self) -> bool: async def run(self) -> bool:
"""Run watch workflow and return success flag.""" """Run watch workflow and return success flag."""
target_issue = self._target.validation_error()
if target_issue:
logger.error(target_issue)
return False
logger.info( logger.info(
"watch session starting (player filter: {})", "watch session starting (player filter: {})",
self._player_hint or "<none>", 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 assert target.allows("org.mpris.MediaPlayer2.mpd") is False
def test_player_target_reports_blacklisted_hint() -> None: def test_player_target_hint_allows_regardless_of_blacklist() -> None:
target = PlayerTarget("spot", player_blacklist=("spotify",)) # --player bypasses PLAYER_BLACKLIST; PlayerTarget.allows() reflects the hint only
assert target.validation_error() is not None target = PlayerTarget("spot")
assert target.allows("org.mpris.MediaPlayer2.spotify") is True
def test_player_target_non_blacklisted_hint_is_valid() -> None:
target = PlayerTarget("mpd", player_blacklist=("spotify",))
assert target.validation_error() is None
# ActivePlayerSelector # ActivePlayerSelector
Generated
+1 -1
View File
@@ -153,7 +153,7 @@ wheels = [
[[package]] [[package]]
name = "lrx-cli" name = "lrx-cli"
version = "0.7.6" version = "0.7.9"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "cyclopts" }, { name = "cyclopts" },