Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
794f42b42b
|
|||
|
b18d860aca
|
@@ -112,7 +112,7 @@ 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"]
|
||||
player_blacklist = ["firefox", "zen", "chrome", "chromium", "vivaldi", "edge", "opera", "mpv"] # bypassed by --player/-p
|
||||
http_timeout = 10.0 # seconds
|
||||
|
||||
[credentials]
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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[
|
||||
|
||||
+44
-29
@@ -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,33 @@ 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 = [
|
||||
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(
|
||||
|
||||
+14
-41
@@ -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,14 @@ 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
|
||||
):
|
||||
continue
|
||||
if not self._target.allows(name):
|
||||
continue
|
||||
@@ -406,23 +396,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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user