feat: --player bypasses players_blacklist
This commit is contained in:
@@ -112,7 +112,7 @@ 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 = "spotify" # 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
@@ -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[
|
||||||
|
|||||||
+44
-29
@@ -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,33 @@ 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:
|
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(
|
||||||
|
|||||||
+14
-41
@@ -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,14 @@ 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
|
||||||
|
):
|
||||||
continue
|
continue
|
||||||
if not self._target.allows(name):
|
if not self._target.allows(name):
|
||||||
continue
|
continue
|
||||||
@@ -406,23 +396,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
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user