feat: config file
refactor: as the config module changed test: add test for config test: add test for local fetcher and local enrichers test: add test for manual insertion fix: some random bugs left by the last commit
This commit is contained in:
@@ -1,13 +1,3 @@
|
||||
import pytest
|
||||
|
||||
from lrx_cli.config import enable_debug
|
||||
|
||||
enable_debug()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_credentials(monkeypatch):
|
||||
"""Clear all credential env vars so only anonymous fetchers are active."""
|
||||
monkeypatch.delenv("SPOTIFY_SP_DC", raising=False)
|
||||
monkeypatch.delenv("QQ_MUSIC_API_URL", raising=False)
|
||||
monkeypatch.delenv("MUSIXMATCH_USERTOKEN", raising=False)
|
||||
|
||||
+10
-8
@@ -1,16 +1,18 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from lrx_cli.config import load_config
|
||||
|
||||
_credentials = load_config().credentials
|
||||
|
||||
requires_spotify = pytest.mark.skipif(
|
||||
not os.environ.get("SPOTIFY_SP_DC"),
|
||||
reason="requires SPOTIFY_SP_DC",
|
||||
not _credentials.spotify_sp_dc,
|
||||
reason="requires credentials.spotify_sp_dc in config.toml",
|
||||
)
|
||||
requires_qq_music = pytest.mark.skipif(
|
||||
not os.environ.get("QQ_MUSIC_API_URL"),
|
||||
reason="requires QQ_MUSIC_API_URL",
|
||||
not _credentials.qq_music_api_url,
|
||||
reason="requires credentials.qq_music_api_url in config.toml",
|
||||
)
|
||||
requires_musixmatch_token = pytest.mark.skipif(
|
||||
not os.environ.get("MUSIXMATCH_USERTOKEN"),
|
||||
reason="requires MUSIXMATCH_USERTOKEN",
|
||||
not _credentials.musixmatch_usertoken,
|
||||
reason="requires credentials.musixmatch_usertoken in config.toml",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
|
||||
from lrx_cli.config import AppConfig, CredentialConfig, WatchConfig, load_config
|
||||
|
||||
|
||||
def test_missing_file_returns_defaults(tmp_path):
|
||||
assert load_config(tmp_path / "nonexistent.toml") == AppConfig()
|
||||
|
||||
|
||||
def test_empty_file_returns_defaults(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_text("")
|
||||
assert load_config(p) == AppConfig()
|
||||
|
||||
|
||||
def test_partial_section_keeps_other_defaults(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_bytes(b"[watch]\ndebounce_ms = 200\n")
|
||||
cfg = load_config(p)
|
||||
assert cfg.watch.debounce_ms == 200
|
||||
assert cfg.watch.calibration_interval_s == WatchConfig().calibration_interval_s
|
||||
|
||||
|
||||
def test_credentials_roundtrip(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_bytes(
|
||||
b"[credentials]\n"
|
||||
b'spotify_sp_dc = "abc"\n'
|
||||
b'qq_music_api_url = "http://localhost:3000"\n'
|
||||
)
|
||||
assert load_config(p).credentials == CredentialConfig(
|
||||
spotify_sp_dc="abc", qq_music_api_url="http://localhost:3000"
|
||||
)
|
||||
|
||||
|
||||
def test_int_coerced_to_float(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_bytes(b"[general]\nhttp_timeout = 5\n")
|
||||
assert load_config(p).general.http_timeout == 5.0
|
||||
|
||||
|
||||
def test_unknown_key_raises(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_bytes(b"[general]\ntypo_key = 1\n")
|
||||
with pytest.raises(ValueError, match="Unknown config keys"):
|
||||
load_config(p)
|
||||
|
||||
|
||||
def test_wrong_type_raises(tmp_path):
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_bytes(b"[watch]\ndebounce_ms = true\n")
|
||||
with pytest.raises(ValueError, match="expected int"):
|
||||
load_config(p)
|
||||
|
||||
|
||||
def test_app_config_is_frozen():
|
||||
cfg = AppConfig()
|
||||
with pytest.raises(Exception):
|
||||
cfg.general = None # type: ignore[misc]
|
||||
+23
-15
@@ -1,14 +1,16 @@
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from lrx_cli.config import AppConfig, load_config
|
||||
from lrx_cli.core import LrcManager
|
||||
from lrx_cli.fetchers import FetcherMethodType
|
||||
from lrx_cli.models import TrackMeta
|
||||
from lrx_cli.core import LrcManager
|
||||
from tests.marks import (
|
||||
requires_spotify,
|
||||
requires_qq_music,
|
||||
requires_musixmatch_token,
|
||||
requires_qq_music,
|
||||
requires_spotify,
|
||||
)
|
||||
|
||||
SAMPLE_SPOTIFY_TRACK: TrackMeta = TrackMeta(
|
||||
@@ -33,7 +35,14 @@ SAMPLE_SPOTIFY_TRACK_ALBUM_ARTIST_MODIFIED = replace(
|
||||
|
||||
@pytest.fixture
|
||||
def lrc_manager(tmp_path: Path) -> LrcManager:
|
||||
return LrcManager(str(tmp_path / "cache.db"))
|
||||
"""LrcManager with empty credentials (no auth required)."""
|
||||
return LrcManager(str(tmp_path / "cache.db"), AppConfig())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cred_lrc_manager(tmp_path: Path) -> LrcManager:
|
||||
"""LrcManager with credentials from config.toml (for CI/network tests)."""
|
||||
return LrcManager(str(tmp_path / "cache.db"), load_config())
|
||||
|
||||
|
||||
def _fetch_and_assert(
|
||||
@@ -112,7 +121,6 @@ def test_cache_search_fetcher_prefer_better_match(lrc_manager: LrcManager):
|
||||
],
|
||||
)
|
||||
def test_anonymous_remote_fetchers(
|
||||
no_credentials,
|
||||
lrc_manager: LrcManager,
|
||||
method: FetcherMethodType,
|
||||
expect_fail: bool,
|
||||
@@ -122,18 +130,18 @@ def test_anonymous_remote_fetchers(
|
||||
|
||||
@pytest.mark.network
|
||||
@requires_spotify
|
||||
def test_spotify_fetcher(lrc_manager: LrcManager):
|
||||
_fetch_and_assert(lrc_manager, "spotify")
|
||||
def test_spotify_fetcher(cred_lrc_manager: LrcManager):
|
||||
_fetch_and_assert(cred_lrc_manager, "spotify")
|
||||
|
||||
|
||||
@pytest.mark.network
|
||||
@requires_qq_music
|
||||
def test_qqmusic_fetcher(lrc_manager: LrcManager):
|
||||
_fetch_and_assert(lrc_manager, "qqmusic")
|
||||
def test_qqmusic_fetcher(cred_lrc_manager: LrcManager):
|
||||
_fetch_and_assert(cred_lrc_manager, "qqmusic")
|
||||
|
||||
|
||||
@pytest.mark.network
|
||||
def test_musixmatch_anonymous_fetcher(no_credentials, lrc_manager: LrcManager):
|
||||
def test_musixmatch_anonymous_fetcher(lrc_manager: LrcManager):
|
||||
# These fetchers should be tested in a single test to share the same usertoken
|
||||
# Otherwise the second may fail due to rate limits
|
||||
_fetch_and_assert(lrc_manager, "musixmatch", expect_fail=False)
|
||||
@@ -142,9 +150,9 @@ def test_musixmatch_anonymous_fetcher(no_credentials, lrc_manager: LrcManager):
|
||||
|
||||
@pytest.mark.network
|
||||
@requires_musixmatch_token
|
||||
def test_musixmatch_fetcher(lrc_manager: LrcManager):
|
||||
_fetch_and_assert(lrc_manager, "musixmatch")
|
||||
_fetch_and_assert(lrc_manager, "musixmatch-spotify")
|
||||
def test_musixmatch_fetcher(cred_lrc_manager: LrcManager):
|
||||
_fetch_and_assert(cred_lrc_manager, "musixmatch")
|
||||
_fetch_and_assert(cred_lrc_manager, "musixmatch-spotify")
|
||||
|
||||
|
||||
def test_local_fetcher(lrc_manager: LrcManager):
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from lrx_cli.config import AppConfig
|
||||
from lrx_cli.enrichers.audio_tag import AudioTagEnricher
|
||||
from lrx_cli.enrichers.file_name import FileNameEnricher
|
||||
from lrx_cli.models import CacheStatus, TrackMeta
|
||||
from lrx_cli.fetchers.local import LocalFetcher
|
||||
|
||||
_GENERAL = AppConfig().general
|
||||
|
||||
|
||||
def _local_track(path: Path) -> TrackMeta:
|
||||
return TrackMeta(url=f"file://{path}")
|
||||
|
||||
|
||||
def test_local_fetcher_unavailable_for_non_local_track():
|
||||
fetcher = LocalFetcher(_GENERAL)
|
||||
assert not fetcher.is_available(TrackMeta(title="Song", artist="Artist"))
|
||||
|
||||
|
||||
def test_local_fetcher_available_for_local_track(tmp_path):
|
||||
fetcher = LocalFetcher(_GENERAL)
|
||||
assert fetcher.is_available(_local_track(tmp_path / "song.flac"))
|
||||
|
||||
|
||||
def test_local_fetcher_returns_empty_for_non_file_url():
|
||||
fetcher = LocalFetcher(_GENERAL)
|
||||
track = TrackMeta(url="https://example.com/song.mp3")
|
||||
result = asyncio.run(fetcher.fetch(track))
|
||||
assert result.synced is None and result.unsynced is None
|
||||
|
||||
|
||||
def test_local_fetcher_reads_synced_sidecar(tmp_path):
|
||||
audio = tmp_path / "song.flac"
|
||||
lrc = audio.with_suffix(".lrc")
|
||||
lrc.write_text("[00:01.00]Hello\n[00:03.00]World\n")
|
||||
|
||||
fetcher = LocalFetcher(_GENERAL)
|
||||
result = asyncio.run(fetcher.fetch(_local_track(audio)))
|
||||
|
||||
assert result.synced is not None
|
||||
assert result.synced.status == CacheStatus.SUCCESS_SYNCED
|
||||
assert result.synced.source is not None
|
||||
assert "sidecar" in result.synced.source
|
||||
|
||||
|
||||
def test_local_fetcher_reads_unsynced_sidecar(tmp_path):
|
||||
audio = tmp_path / "song.flac"
|
||||
lrc = audio.with_suffix(".lrc")
|
||||
lrc.write_text("Hello\nWorld\n")
|
||||
|
||||
fetcher = LocalFetcher(_GENERAL)
|
||||
result = asyncio.run(fetcher.fetch(_local_track(audio)))
|
||||
|
||||
assert result.unsynced is not None
|
||||
assert result.synced is None
|
||||
|
||||
|
||||
def test_local_fetcher_empty_sidecar_ignored(tmp_path):
|
||||
audio = tmp_path / "song.flac"
|
||||
(audio.with_suffix(".lrc")).write_text(" ")
|
||||
|
||||
fetcher = LocalFetcher(_GENERAL)
|
||||
result = asyncio.run(fetcher.fetch(_local_track(audio)))
|
||||
|
||||
assert result.synced is None and result.unsynced is None
|
||||
|
||||
|
||||
def _enrich(path: str, **existing) -> dict | None:
|
||||
enricher = FileNameEnricher()
|
||||
track = TrackMeta(url=f"file://{path}", **existing)
|
||||
return asyncio.run(enricher.enrich(track))
|
||||
|
||||
|
||||
def test_filename_enricher_artist_title_split(tmp_path):
|
||||
result = _enrich(str(tmp_path / "Utada Hikaru - First Love.flac"))
|
||||
assert result == {
|
||||
"artist": "Utada Hikaru",
|
||||
"title": "First Love",
|
||||
"album": tmp_path.name,
|
||||
}
|
||||
|
||||
|
||||
def test_filename_enricher_track_number_prefix(tmp_path):
|
||||
# "01. Title" — no " - " separator, regex strips leading "01. "
|
||||
result = _enrich(str(tmp_path / "01. First Love.flac"))
|
||||
assert result and result.get("title") == "First Love"
|
||||
assert "artist" not in result
|
||||
|
||||
|
||||
def test_filename_enricher_title_only(tmp_path):
|
||||
result = _enrich(str(tmp_path / "First Love.flac"))
|
||||
assert result and result.get("title") == "First Love"
|
||||
|
||||
|
||||
def test_filename_enricher_does_not_overwrite_existing_fields(tmp_path):
|
||||
result = _enrich(
|
||||
str(tmp_path / "Artist - Title.flac"),
|
||||
artist="Existing Artist",
|
||||
title="Existing Title",
|
||||
)
|
||||
assert result is None or ("artist" not in result and "title" not in result)
|
||||
|
||||
|
||||
def test_filename_enricher_non_local_returns_none():
|
||||
enricher = FileNameEnricher()
|
||||
track = TrackMeta(title="Song", artist="Artist")
|
||||
assert asyncio.run(enricher.enrich(track)) is None
|
||||
|
||||
|
||||
def test_audio_tag_enricher_non_local_returns_none():
|
||||
enricher = AudioTagEnricher()
|
||||
track = TrackMeta(title="Song", artist="Artist")
|
||||
assert asyncio.run(enricher.enrich(track)) is None
|
||||
|
||||
|
||||
def test_audio_tag_enricher_missing_file_returns_none(tmp_path):
|
||||
enricher = AudioTagEnricher()
|
||||
track = _local_track(tmp_path / "nonexistent.flac")
|
||||
assert asyncio.run(enricher.enrich(track)) is None
|
||||
@@ -277,3 +277,53 @@ def test_unsynced_cache_only_still_fetches_when_unsynced_disallowed(tmp_path):
|
||||
assert fetcher.called
|
||||
assert result is not None
|
||||
assert result.status == CacheStatus.SUCCESS_SYNCED
|
||||
|
||||
|
||||
# manual_insert
|
||||
|
||||
|
||||
def test_manual_insert_synced_stored_with_correct_status(tmp_path):
|
||||
manager = make_manager(tmp_path)
|
||||
manager.manual_insert(_track(), "[00:01.00]Hello\n[00:03.00]World\n")
|
||||
|
||||
rows = manager.cache.query_track(_track())
|
||||
assert any(r["status"] == CacheStatus.SUCCESS_SYNCED.value for r in rows)
|
||||
|
||||
|
||||
def test_manual_insert_unsynced_stored_with_correct_status(tmp_path):
|
||||
manager = make_manager(tmp_path)
|
||||
manager.manual_insert(_track(), "Hello\nWorld\n")
|
||||
|
||||
rows = manager.cache.query_track(_track())
|
||||
assert any(r["status"] == CacheStatus.SUCCESS_UNSYNCED.value for r in rows)
|
||||
|
||||
|
||||
def test_manual_insert_source_and_ttl(tmp_path):
|
||||
manager = make_manager(tmp_path)
|
||||
manager.manual_insert(_track(), "[00:01.00]line\n")
|
||||
|
||||
rows = manager.cache.query_track(_track())
|
||||
assert all(r["source"] == "manual" for r in rows)
|
||||
assert all(r["expires_at"] is None for r in rows)
|
||||
|
||||
|
||||
def test_manual_insert_overwrites_previous_entry(tmp_path):
|
||||
manager = make_manager(tmp_path)
|
||||
track = _track()
|
||||
manager.manual_insert(track, "[00:01.00]old\n")
|
||||
manager.manual_insert(track, "[00:01.00]new\n")
|
||||
|
||||
best = manager.cache.get_best(track, ["manual"])
|
||||
assert best is not None
|
||||
assert str(best.lyrics) == "[00:01.00]new"
|
||||
|
||||
|
||||
def test_manual_insert_is_returned_by_fetch(tmp_path):
|
||||
manager = make_manager(tmp_path)
|
||||
track = _track()
|
||||
manager.manual_insert(track, "[00:01.00]cached\n")
|
||||
|
||||
result = manager.fetch_for_track(track)
|
||||
assert result is not None
|
||||
assert result.lyrics is not None
|
||||
assert str(result.lyrics) == "[00:01.00]cached"
|
||||
|
||||
+21
-28
@@ -10,19 +10,12 @@ from lrx_cli.watch.view import BaseOutput, LyricView, WatchState
|
||||
from lrx_cli.watch.view.pipe import PipeOutput
|
||||
from lrx_cli.watch.player import ActivePlayerSelector, PlayerState, PlayerTarget
|
||||
from lrx_cli.watch.fetcher import LyricFetcher
|
||||
from lrx_cli.watch.options import WatchOptions
|
||||
from lrx_cli.config import AppConfig
|
||||
from lrx_cli.watch.tracker import PositionTracker
|
||||
from lrx_cli.watch.session import WatchCoordinator
|
||||
|
||||
|
||||
TEST_WATCH_OPTIONS = WatchOptions(
|
||||
preferred_player="spotify",
|
||||
player_blacklist=(),
|
||||
debounce_ms=400,
|
||||
position_tick_ms=50,
|
||||
calibration_interval_s=3.0,
|
||||
socket_path=Path("/tmp/lrx-watch-test.sock"),
|
||||
)
|
||||
TEST_CONFIG = AppConfig()
|
||||
|
||||
|
||||
def test_parse_delta_supports_plus_minus_and_reset() -> None:
|
||||
@@ -64,7 +57,7 @@ def test_active_player_selector_prefers_single_playing() -> None:
|
||||
),
|
||||
}
|
||||
assert (
|
||||
ActivePlayerSelector.select(players, None, TEST_WATCH_OPTIONS)
|
||||
ActivePlayerSelector.select(players, None, TEST_CONFIG)
|
||||
== "org.mpris.MediaPlayer2.bar"
|
||||
)
|
||||
|
||||
@@ -87,7 +80,7 @@ def test_active_player_selector_uses_last_active_when_no_playing() -> None:
|
||||
ActivePlayerSelector.select(
|
||||
players,
|
||||
"org.mpris.MediaPlayer2.bar",
|
||||
TEST_WATCH_OPTIONS,
|
||||
TEST_CONFIG,
|
||||
)
|
||||
== "org.mpris.MediaPlayer2.bar"
|
||||
)
|
||||
@@ -98,7 +91,7 @@ def test_position_tracker_seeked_calibrates_immediately() -> None:
|
||||
async def _poll(_bus: str):
|
||||
return 1200
|
||||
|
||||
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS)
|
||||
tracker = PositionTracker(_poll, TEST_CONFIG)
|
||||
await tracker.start()
|
||||
await tracker.set_active_player(
|
||||
"org.mpris.MediaPlayer2.foo", "Playing", "track-A"
|
||||
@@ -116,7 +109,7 @@ def test_position_tracker_playback_status_pause_stops_fast_growth() -> None:
|
||||
async def _poll(_bus: str):
|
||||
return 0
|
||||
|
||||
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS)
|
||||
tracker = PositionTracker(_poll, TEST_CONFIG)
|
||||
await tracker.start()
|
||||
await tracker.set_active_player(
|
||||
"org.mpris.MediaPlayer2.foo", "Playing", "track-A"
|
||||
@@ -140,7 +133,7 @@ def test_position_tracker_playback_status_playing_calibrates_once() -> None:
|
||||
async def _poll(_bus: str):
|
||||
return 50000
|
||||
|
||||
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS)
|
||||
tracker = PositionTracker(_poll, TEST_CONFIG)
|
||||
await tracker.start()
|
||||
await tracker.set_active_player(
|
||||
"org.mpris.MediaPlayer2.foo", "Paused", "track-A"
|
||||
@@ -159,7 +152,7 @@ def test_position_tracker_set_active_player_playing_calibrates_on_resume() -> No
|
||||
async def _poll(_bus: str):
|
||||
return 42000
|
||||
|
||||
tracker = PositionTracker(_poll, TEST_WATCH_OPTIONS)
|
||||
tracker = PositionTracker(_poll, TEST_CONFIG)
|
||||
await tracker.start()
|
||||
await tracker.set_active_player(
|
||||
"org.mpris.MediaPlayer2.foo", "Paused", "track-A"
|
||||
@@ -189,11 +182,11 @@ def test_control_server_and_client_roundtrip(tmp_path: Path) -> None:
|
||||
return {"ok": True, "offset_ms": self.offset, "lyrics_status": "idle"}
|
||||
|
||||
socket_path = tmp_path / "watch.sock"
|
||||
server = ControlServer(socket_path=socket_path, options=TEST_WATCH_OPTIONS)
|
||||
server = ControlServer(socket_path=socket_path, config=TEST_CONFIG)
|
||||
session = _Session()
|
||||
|
||||
await server.start(session)
|
||||
client = ControlClient(socket_path=socket_path, options=TEST_WATCH_OPTIONS)
|
||||
await server.start(session) # type: ignore
|
||||
client = ControlClient(socket_path=socket_path, config=TEST_CONFIG)
|
||||
r1 = await client._send_async({"cmd": "offset", "delta": 200})
|
||||
r2 = await client._send_async({"cmd": "status"})
|
||||
await server.stop()
|
||||
@@ -327,23 +320,23 @@ def test_session_fetches_on_resume_playing_without_lyrics() -> None:
|
||||
async def _on_result(_lyrics) -> None:
|
||||
return None
|
||||
|
||||
super().__init__(_fetch, _on_fetching, _on_result, TEST_WATCH_OPTIONS)
|
||||
super().__init__(_fetch, _on_fetching, _on_result, TEST_CONFIG)
|
||||
self.requested = []
|
||||
|
||||
def request(self, track: TrackMeta) -> None:
|
||||
self.requested.append(track.display_name())
|
||||
|
||||
session = WatchCoordinator(
|
||||
_Manager(),
|
||||
_Manager(), # type: ignore
|
||||
_Output(),
|
||||
player_hint=None,
|
||||
options=TEST_WATCH_OPTIONS,
|
||||
config=TEST_CONFIG,
|
||||
)
|
||||
fake_fetcher = _Fetcher()
|
||||
session._fetcher = fake_fetcher
|
||||
session._tracker = PositionTracker(
|
||||
lambda _bus: asyncio.sleep(0, result=0),
|
||||
TEST_WATCH_OPTIONS,
|
||||
TEST_CONFIG,
|
||||
)
|
||||
|
||||
bus_name = "org.mpris.MediaPlayer2.spotify"
|
||||
@@ -379,14 +372,14 @@ def test_session_emit_state_only_when_lyric_cursor_changes() -> None:
|
||||
|
||||
output = _Output()
|
||||
session = WatchCoordinator(
|
||||
_Manager(),
|
||||
_Manager(), # type: ignore
|
||||
output,
|
||||
player_hint=None,
|
||||
options=TEST_WATCH_OPTIONS,
|
||||
config=TEST_CONFIG,
|
||||
)
|
||||
session._tracker = PositionTracker(
|
||||
lambda _bus: asyncio.sleep(0, result=0),
|
||||
TEST_WATCH_OPTIONS,
|
||||
TEST_CONFIG,
|
||||
)
|
||||
|
||||
bus_name = "org.mpris.MediaPlayer2.spotify"
|
||||
@@ -429,14 +422,14 @@ def test_session_emits_when_crossing_first_timestamp() -> None:
|
||||
|
||||
output = _Output()
|
||||
session = WatchCoordinator(
|
||||
_Manager(),
|
||||
_Manager(), # type: ignore
|
||||
output,
|
||||
player_hint=None,
|
||||
options=TEST_WATCH_OPTIONS,
|
||||
config=TEST_CONFIG,
|
||||
)
|
||||
session._tracker = PositionTracker(
|
||||
lambda _bus: asyncio.sleep(0, result=0),
|
||||
TEST_WATCH_OPTIONS,
|
||||
TEST_CONFIG,
|
||||
)
|
||||
|
||||
bus_name = "org.mpris.MediaPlayer2.spotify"
|
||||
|
||||
Reference in New Issue
Block a user