Compare commits
7 Commits
1771b43bba
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
7fd98ffcb9
|
|||
|
cfac95ca03
|
|||
|
10bfb6090c
|
|||
|
c8ccf31583
|
|||
|
6e971941f8
|
|||
|
794f42b42b
|
|||
|
b18d860aca
|
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
@@ -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[
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
|
||||||
|
|||||||
@@ -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