├── tests ├── __init__.py ├── dummy_mixer.py ├── test_extension.py ├── conftest.py ├── test_root.py ├── dummy_audio.py ├── test_playlists.py ├── dummy_backend.py ├── test_events.py └── test_player.py ├── src └── mopidy_mpris │ ├── ext.conf │ ├── interface.py │ ├── __init__.py │ ├── server.py │ ├── root.py │ ├── playlists.py │ ├── frontend.py │ └── player.py ├── .mailmap ├── .gitignore ├── .copier-answers.yml ├── .github └── workflows │ ├── release.yml │ └── ci.yml ├── pyproject.toml ├── LICENSE └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/mopidy_mpris/ext.conf: -------------------------------------------------------------------------------- 1 | [mpris] 2 | enabled = true 3 | bus_type = session 4 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Tobias Laundal 2 | Marcin Klimczak 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | /*.lock 3 | /.*_cache/ 4 | /.coverage 5 | /.tox/ 6 | /.venv/ 7 | /build/ 8 | /dist/ 9 | __pycache__/ 10 | -------------------------------------------------------------------------------- /.copier-answers.yml: -------------------------------------------------------------------------------- 1 | _commit: v2.2.0 2 | _src_path: gh:mopidy/mopidy-ext-template 3 | author_email: stein.magnus@jodal.no 4 | author_full_name: Stein Magnus Jodal 5 | dist_name: mopidy-mpris 6 | ext_name: mpris 7 | github_username: mopidy 8 | short_description: Mopidy extension for controlling Mopidy through the MPRIS D-Bus 9 | interface 10 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-24.04 10 | environment: 11 | name: pypi 12 | url: https://pypi.org/project/mopidy-mpris/ 13 | permissions: 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v6 17 | - uses: hynek/build-and-inspect-python-package@v2 18 | id: build 19 | - uses: actions/download-artifact@v4 20 | with: 21 | name: ${{ steps.build.outputs.artifact-name }} 22 | path: dist 23 | - uses: pypa/gh-action-pypi-publish@release/v1 24 | -------------------------------------------------------------------------------- /tests/dummy_mixer.py: -------------------------------------------------------------------------------- 1 | import pykka 2 | from mopidy import mixer 3 | 4 | 5 | def create_proxy(config=None): 6 | return DummyMixer.start(config=None).proxy() 7 | 8 | 9 | class DummyMixer(pykka.ThreadingActor, mixer.Mixer): 10 | def __init__(self, config): 11 | super().__init__() 12 | self._volume = None 13 | self._mute = None 14 | 15 | def get_volume(self): 16 | return self._volume 17 | 18 | def set_volume(self, volume): 19 | self._volume = volume 20 | self.trigger_volume_changed(volume=volume) 21 | return True 22 | 23 | def get_mute(self): 24 | return self._mute 25 | 26 | def set_mute(self, mute): 27 | self._mute = mute 28 | self.trigger_mute_changed(mute=mute) 29 | return True 30 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from mopidy_mpris import Extension 4 | from mopidy_mpris import frontend as frontend_lib 5 | 6 | 7 | def test_get_default_config(): 8 | ext = Extension() 9 | 10 | config = ext.get_default_config() 11 | 12 | assert "[mpris]" in config 13 | assert "enabled = true" in config 14 | assert "bus_type = session" in config 15 | 16 | 17 | def test_get_config_schema(): 18 | ext = Extension() 19 | 20 | schema = ext.get_config_schema() 21 | 22 | assert "desktop_file" in schema 23 | assert "bus_type" in schema 24 | 25 | 26 | def test_get_frontend_classes(): 27 | ext = Extension() 28 | registry = mock.Mock() 29 | 30 | ext.setup(registry) 31 | 32 | registry.add.assert_called_once_with("frontend", frontend_lib.MprisFrontend) 33 | -------------------------------------------------------------------------------- /src/mopidy_mpris/interface.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, Any, ClassVar 5 | 6 | from pydbus.generic import signal 7 | 8 | if TYPE_CHECKING: 9 | from mopidy.config import Config 10 | from mopidy.core import CoreProxy 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | # This should be kept in sync with mopidy.internal.log.TRACE_LOG_LEVEL 15 | TRACE_LOG_LEVEL = 5 16 | 17 | 18 | class Interface: 19 | INTERFACE: ClassVar[str] 20 | 21 | def __init__(self, config: Config, core: CoreProxy) -> None: 22 | self.config = config 23 | self.core = core 24 | 25 | PropertiesChanged = signal() 26 | 27 | def log_trace(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 28 | logger.log(TRACE_LOG_LEVEL, *args, **kwargs) 29 | -------------------------------------------------------------------------------- /src/mopidy_mpris/__init__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from importlib.metadata import version 3 | 4 | from mopidy import config, exceptions, ext 5 | from mopidy.config import ConfigSchema 6 | 7 | __version__ = version("mopidy-mpris") 8 | 9 | 10 | class Extension(ext.Extension): 11 | dist_name = "mopidy-mpris" 12 | ext_name = "mpris" 13 | version = __version__ 14 | 15 | def get_default_config(self) -> str: 16 | return config.read(pathlib.Path(__file__).parent / "ext.conf") 17 | 18 | def get_config_schema(self) -> ConfigSchema: 19 | schema = super().get_config_schema() 20 | schema["desktop_file"] = config.Deprecated() 21 | schema["bus_type"] = config.String(choices=["session", "system"]) 22 | return schema 23 | 24 | def validate_environment(self) -> None: 25 | try: 26 | import pydbus # noqa: F401, PLC0415 27 | except ImportError as exc: 28 | msg = "pydbus library not found" 29 | raise exceptions.ExtensionError(msg, exc) from exc 30 | 31 | def setup(self, registry: ext.Registry) -> None: 32 | from mopidy_mpris.frontend import MprisFrontend # noqa: PLC0415 33 | 34 | registry.add("frontend", MprisFrontend) 35 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, cast 4 | 5 | import pytest 6 | from mopidy.core import Core 7 | 8 | from tests import dummy_audio, dummy_backend, dummy_mixer 9 | 10 | if TYPE_CHECKING: 11 | from collections.abc import Generator 12 | 13 | from mopidy.audio import AudioProxy 14 | from mopidy.backend import BackendProxy 15 | from mopidy.core import CoreProxy 16 | from mopidy.mixer import MixerProxy 17 | 18 | 19 | @pytest.fixture 20 | def config(): 21 | return { 22 | "core": {"max_tracklist_length": 10000}, 23 | } 24 | 25 | 26 | @pytest.fixture 27 | def audio() -> Generator[AudioProxy]: 28 | actor = cast("AudioProxy", dummy_audio.create_proxy()) 29 | yield actor 30 | actor.stop() 31 | 32 | 33 | @pytest.fixture 34 | def backend(audio) -> Generator[BackendProxy]: 35 | actor = cast("BackendProxy", dummy_backend.create_proxy(audio=audio)) 36 | yield actor 37 | actor.stop() 38 | 39 | 40 | @pytest.fixture 41 | def mixer() -> Generator[MixerProxy]: 42 | actor = cast("MixerProxy", dummy_mixer.create_proxy()) 43 | yield actor 44 | actor.stop() 45 | 46 | 47 | @pytest.fixture 48 | def core(config, backend, mixer, audio) -> Generator[CoreProxy]: 49 | actor = cast( 50 | "CoreProxy", 51 | Core.start( 52 | config=config, 53 | backends=[backend], 54 | mixer=mixer, 55 | audio=audio, 56 | ).proxy(), 57 | ) 58 | yield actor 59 | actor.stop() 60 | -------------------------------------------------------------------------------- /src/mopidy_mpris/server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING 5 | 6 | import pydbus 7 | 8 | from mopidy_mpris.player import Player 9 | from mopidy_mpris.playlists import Playlists 10 | from mopidy_mpris.root import Root 11 | 12 | if TYPE_CHECKING: 13 | from mopidy.config import Config 14 | from mopidy.core import CoreProxy 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class Server: 20 | def __init__(self, config: Config, core: CoreProxy) -> None: 21 | self.config = config 22 | self.core = core 23 | 24 | self.root = Root(config, core) 25 | self.player = Player(config, core) 26 | self.playlists = Playlists(config, core) 27 | 28 | self._publication_token = None 29 | 30 | def publish(self) -> None: 31 | bus_type = self.config["mpris"]["bus_type"] # pyright: ignore[reportGeneralTypeIssues] 32 | logger.debug("Connecting to D-Bus %s bus...", bus_type) 33 | 34 | bus = pydbus.SystemBus() if bus_type == "system" else pydbus.SessionBus() 35 | 36 | logger.info("MPRIS server connected to D-Bus %s bus", bus_type) 37 | 38 | self._publication_token = bus.publish( 39 | "org.mpris.MediaPlayer2.mopidy", 40 | ("/org/mpris/MediaPlayer2", self.root), 41 | ("/org/mpris/MediaPlayer2", self.player), 42 | ("/org/mpris/MediaPlayer2", self.playlists), 43 | ) 44 | 45 | def unpublish(self) -> None: 46 | if self._publication_token: 47 | self._publication_token.unpublish() 48 | self._publication_token = None 49 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | name: Build 13 | runs-on: ubuntu-24.04 14 | steps: 15 | - uses: actions/checkout@v6 16 | - uses: hynek/build-and-inspect-python-package@v2 17 | 18 | main: 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | include: 23 | - name: "pytest (3.13)" 24 | python: "3.13" 25 | tox: "3.13" 26 | - name: "pytest (3.14)" 27 | python: "3.14" 28 | tox: "3.14" 29 | coverage: true 30 | - name: "pyright" 31 | python: "3.14" 32 | tox: "pyright" 33 | - name: "ruff check" 34 | python: "3.14" 35 | tox: "ruff-check" 36 | - name: "ruff format" 37 | python: "3.14" 38 | tox: "ruff-format" 39 | 40 | name: ${{ matrix.name }} 41 | runs-on: ubuntu-24.04 42 | container: ghcr.io/mopidy/ci:latest 43 | 44 | steps: 45 | - uses: actions/checkout@v6 46 | - name: Fix home dir permissions to enable pip caching 47 | run: chown -R root /github/home 48 | - uses: actions/setup-python@v6 49 | with: 50 | python-version: ${{ matrix.python }} 51 | cache: pip 52 | allow-prereleases: true 53 | - run: python -m pip install tox 54 | - run: python -m tox -e ${{ matrix.tox }} 55 | if: ${{ ! matrix.coverage }} 56 | - run: python -m tox -e ${{ matrix.tox }} -- --cov-report=xml 57 | if: ${{ matrix.coverage }} 58 | - uses: codecov/codecov-action@v5 59 | if: ${{ matrix.coverage }} 60 | with: 61 | token: ${{ secrets.CODECOV_TOKEN }} 62 | -------------------------------------------------------------------------------- /tests/test_root.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from mopidy_mpris.root import Root 8 | 9 | if TYPE_CHECKING: 10 | from mopidy.config import Config 11 | from mopidy.core import CoreProxy 12 | 13 | 14 | @pytest.fixture 15 | def root(config: Config, core: CoreProxy) -> Root: 16 | return Root(config, core) 17 | 18 | 19 | def test_fullscreen_returns_false(root: Root): 20 | assert root.Fullscreen is False 21 | 22 | 23 | def test_setting_fullscreen_fails(root: Root): 24 | root.Fullscreen = True 25 | 26 | assert root.Fullscreen is False 27 | 28 | 29 | def test_can_set_fullscreen_returns_false(root: Root): 30 | assert root.CanSetFullscreen is False 31 | 32 | 33 | def test_can_raise_returns_false(root: Root): 34 | assert root.CanRaise is False 35 | 36 | 37 | def test_raise_does_nothing(root: Root): 38 | root.Raise() 39 | 40 | 41 | def test_can_quit_returns_false(root: Root): 42 | assert root.CanQuit is False 43 | 44 | 45 | def test_quit_does_nothing(root: Root): 46 | root.Quit() 47 | 48 | 49 | def test_has_track_list_returns_false(root: Root): 50 | assert root.HasTrackList is False 51 | 52 | 53 | def test_identify_is_mopidy(root: Root): 54 | assert root.Identity == "Mopidy" 55 | 56 | 57 | def test_desktop_entry_is_blank(root, config): 58 | assert root.DesktopEntry == "" 59 | 60 | 61 | def test_supported_uri_schemes_includes_backend_uri_schemes(root: Root): 62 | assert root.SupportedUriSchemes == ["dummy"] 63 | 64 | 65 | def test_supported_mime_types_has_hardcoded_entries(root: Root): 66 | assert root.SupportedMimeTypes == [ 67 | "audio/mpeg", 68 | "audio/x-ms-wma", 69 | "audio/x-ms-asf", 70 | "audio/x-flac", 71 | "audio/flac", 72 | "audio/l16;channels=2;rate=44100", 73 | "audio/l16;rate=44100;channels=2", 74 | ] 75 | -------------------------------------------------------------------------------- /src/mopidy_mpris/root.py: -------------------------------------------------------------------------------- 1 | """Implementation of org.mpris.MediaPlayer2 interface. 2 | 3 | https://specifications.freedesktop.org/mpris/latest/Media_Player.html 4 | """ 5 | 6 | # ruff: noqa: N802 7 | 8 | import logging 9 | 10 | from mopidy_mpris.interface import Interface 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Root(Interface): 16 | """ 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | """ 33 | 34 | INTERFACE = "org.mpris.MediaPlayer2" 35 | 36 | def Raise(self) -> None: 37 | logger.debug("%s.Raise called", self.INTERFACE) 38 | # Do nothing, as we do not have a GUI 39 | 40 | def Quit(self) -> None: 41 | logger.debug("%s.Quit called", self.INTERFACE) 42 | # Do nothing, as we do not allow MPRIS clients to shut down Mopidy 43 | 44 | CanQuit = False 45 | 46 | @property 47 | def Fullscreen(self) -> bool: 48 | self.log_trace("Getting %s.Fullscreen", self.INTERFACE) 49 | return False 50 | 51 | @Fullscreen.setter 52 | def Fullscreen(self, value: bool) -> None: 53 | logger.debug("Setting %s.Fullscreen to %s", self.INTERFACE, value) 54 | 55 | CanSetFullscreen = False 56 | CanRaise = False 57 | HasTrackList = False # NOTE Change if adding optional track list support 58 | Identity = "Mopidy" 59 | 60 | @property 61 | def DesktopEntry(self) -> str: 62 | self.log_trace("Getting %s.DesktopEntry", self.INTERFACE) 63 | # This property is optional to expose. If we set this to "mopidy", the 64 | # basename of "mopidy.desktop", some MPRIS clients will start a new 65 | # Mopidy instance in a terminal window if one clicks outside the 66 | # buttons of the UI. This is probably never what the user wants. 67 | return "" 68 | 69 | @property 70 | def SupportedUriSchemes(self) -> list[str]: 71 | self.log_trace("Getting %s.SupportedUriSchemes", self.INTERFACE) 72 | return [str(uri_scheme) for uri_scheme in self.core.get_uri_schemes().get()] 73 | 74 | @property 75 | def SupportedMimeTypes(self) -> list[str]: 76 | # NOTE Return MIME types supported by local backend if support for 77 | # reporting supported MIME types is added. 78 | self.log_trace("Getting %s.SupportedMimeTypes", self.INTERFACE) 79 | return [ 80 | "audio/mpeg", 81 | "audio/x-ms-wma", 82 | "audio/x-ms-asf", 83 | "audio/x-flac", 84 | "audio/flac", 85 | "audio/l16;channels=2;rate=44100", 86 | "audio/l16;rate=44100;channels=2", 87 | ] 88 | -------------------------------------------------------------------------------- /tests/dummy_audio.py: -------------------------------------------------------------------------------- 1 | """A dummy audio actor for use in tests. 2 | 3 | This class implements the audio API in the simplest way possible. It is used in 4 | tests of the core and backends. 5 | """ 6 | 7 | from typing import override 8 | 9 | import pykka 10 | from mopidy import audio 11 | from mopidy.types import DurationMs, PlaybackState 12 | 13 | 14 | def create_proxy(config=None, mixer=None): 15 | return DummyAudio.start(config, mixer).proxy() 16 | 17 | 18 | # TODO: reset position on track change? 19 | class DummyAudio(audio.Audio, pykka.ThreadingActor): 20 | def __init__(self, config=None, mixer=None): 21 | super().__init__() 22 | self.state = PlaybackState.STOPPED 23 | self._position = DurationMs(0) 24 | self._source_setup_callback = None 25 | self._about_to_finish_callback = None 26 | self._uri = None 27 | self._stream_changed = False 28 | self._live_stream = False 29 | self._tags = {} 30 | self._bad_uris = set() 31 | 32 | @override 33 | def set_uri(self, uri, live_stream=False, download=False): 34 | assert self._uri is None, "prepare change not called before set" 35 | self._position = DurationMs(0) 36 | self._uri = uri 37 | self._stream_changed = True 38 | self._live_stream = live_stream 39 | self._tags = {} 40 | 41 | @override 42 | def set_source_setup_callback(self, callback): 43 | self._source_setup_callback = callback 44 | 45 | @override 46 | def set_about_to_finish_callback(self, callback): 47 | self._about_to_finish_callback = callback 48 | 49 | @override 50 | def get_position(self): 51 | return self._position 52 | 53 | @override 54 | def set_position(self, position): 55 | self._position = position 56 | audio.AudioListener.send("position_changed", position=position) 57 | return True 58 | 59 | @override 60 | def start_playback(self): 61 | return self._change_state(PlaybackState.PLAYING) 62 | 63 | @override 64 | def pause_playback(self): 65 | return self._change_state(PlaybackState.PAUSED) 66 | 67 | @override 68 | def prepare_change(self): 69 | self._uri = None 70 | self._source_setup_callback = None 71 | return True 72 | 73 | @override 74 | def stop_playback(self): 75 | return self._change_state(PlaybackState.STOPPED) 76 | 77 | @override 78 | def get_current_tags(self): 79 | return self._tags 80 | 81 | def _change_state(self, new_state): 82 | if not self._uri: 83 | return False 84 | 85 | if new_state == PlaybackState.STOPPED and self._uri: 86 | self._stream_changed = True 87 | self._uri = None 88 | 89 | if self._stream_changed: 90 | self._stream_changed = False 91 | audio.AudioListener.send("stream_changed", uri=self._uri) 92 | 93 | if self._uri is not None: 94 | audio.AudioListener.send("position_changed", position=0) 95 | 96 | old_state, self.state = self.state, new_state 97 | audio.AudioListener.send( 98 | "state_changed", 99 | old_state=old_state, 100 | new_state=new_state, 101 | target_state=None, 102 | ) 103 | 104 | if new_state == PlaybackState.PLAYING: 105 | self._tags["audio-codec"] = ["fake info..."] 106 | audio.AudioListener.send("tags_changed", tags=["audio-codec"]) 107 | 108 | return self._uri not in self._bad_uris 109 | 110 | def trigger_fake_tags_changed(self, tags): 111 | self._tags.update(tags) 112 | audio.AudioListener.send("tags_changed", tags=self._tags.keys()) 113 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mopidy-mpris" 3 | description = "Mopidy extension for controlling Mopidy through the MPRIS D-Bus interface" 4 | readme = "README.md" 5 | requires-python = ">= 3.13" 6 | license = { text = "Apache-2.0" } 7 | authors = [{ name = "Stein Magnus Jodal", email = "stein.magnus@jodal.no" }] 8 | classifiers = [ 9 | "Environment :: No Input/Output (Daemon)", 10 | "Intended Audience :: End Users/Desktop", 11 | "License :: OSI Approved :: Apache Software License", 12 | "Operating System :: OS Independent", 13 | "Topic :: Multimedia :: Sound/Audio :: Players", 14 | ] 15 | dynamic = ["version"] 16 | dependencies = [ 17 | "mopidy >= 4.0.0a9", 18 | "pydbus >= 0.6.0", 19 | "pygobject >= 3.50", 20 | "pykka >= 4.1", 21 | ] 22 | 23 | [project.urls] 24 | Homepage = "https://github.com/mopidy/mopidy-mpris" 25 | 26 | [project.entry-points."mopidy.ext"] 27 | mpris = "mopidy_mpris:Extension" 28 | 29 | 30 | [build-system] 31 | requires = ["setuptools >= 78", "setuptools-scm >= 8.2"] 32 | build-backend = "setuptools.build_meta" 33 | 34 | 35 | [dependency-groups] 36 | dev = [ 37 | "tox", 38 | { include-group = "ruff" }, 39 | { include-group = "tests" }, 40 | { include-group = "typing" }, 41 | ] 42 | ruff = ["ruff"] 43 | tests = ["pytest", "pytest-cov"] 44 | typing = ["pyright"] 45 | 46 | 47 | [tool.coverage.paths] 48 | source = ["src/", "*/site-packages/"] 49 | 50 | [tool.coverage.run] 51 | source_pkgs = ["mopidy_mpris"] 52 | 53 | [tool.coverage.report] 54 | show_missing = true 55 | 56 | 57 | [tool.pyright] 58 | pythonVersion = "3.13" 59 | typeCheckingMode = "standard" 60 | # Not all dependencies have type hints: 61 | reportMissingTypeStubs = false 62 | # Already covered by ruff's flake8-self checks: 63 | reportPrivateImportUsage = false 64 | 65 | 66 | [tool.pytest.ini_options] 67 | filterwarnings = [ 68 | # By default, fail tests on warnings from our own code 69 | "error:::mopidy_mpris", 70 | # 71 | # Add any warnings you want to ignore here 72 | ] 73 | 74 | 75 | [tool.ruff] 76 | target-version = "py313" 77 | 78 | [tool.ruff.lint] 79 | select = ["ALL"] 80 | ignore = [ 81 | # Add rules you want to ignore here 82 | "D", # pydocstyle 83 | "D203", # one-blank-line-before-class 84 | "D213", # multi-line-summary-second-line 85 | "FIX002", # line-contains-todo 86 | "G004", # logging-f-string 87 | "S101", # assert 88 | "TD002", # missing-todo-author 89 | "TD003", # missing-todo-link 90 | # 91 | # Conflicting with `ruff format` 92 | "COM812", # missing-trailing-comma 93 | "ISC001", # single-line-implicit-string-concatenation 94 | ] 95 | 96 | [tool.ruff.lint.per-file-ignores] 97 | "tests/*" = [ 98 | "ANN", # flake8-annotations 99 | "ARG", # flake8-unused-arguments 100 | "D", # pydocstyle 101 | "FBT003", # boolean-positional-value-in-call -- TODO 102 | "PLR2004", # magic-value-comparison 103 | "S101", # assert 104 | "SLF001", # private-member-access 105 | ] 106 | 107 | 108 | [tool.setuptools.package-data] 109 | "*" = ["*.conf"] 110 | 111 | 112 | [tool.setuptools_scm] 113 | # This section, even if empty, must be present for setuptools_scm to work 114 | 115 | 116 | [tool.tox] 117 | env_list = [ 118 | "3.13", 119 | "3.14", 120 | "pyright", 121 | "ruff-check", 122 | "ruff-format", 123 | ] 124 | 125 | [tool.tox.env_run_base] 126 | package = "wheel" 127 | wheel_build_env = ".pkg" 128 | dependency_groups = ["tests"] 129 | commands = [ 130 | [ 131 | "pytest", 132 | "--cov", 133 | "--basetemp={envtmpdir}", 134 | { replace = "posargs", extend = true }, 135 | ], 136 | ] 137 | 138 | [tool.tox.env.pyright] 139 | dependency_groups = ["typing"] 140 | commands = [["pyright", "{posargs:src}"]] 141 | 142 | [tool.tox.env.ruff-check] 143 | skip_install = true 144 | dependency_groups = ["ruff"] 145 | commands = [["ruff", "check", "{posargs:.}"]] 146 | 147 | [tool.tox.env.ruff-format] 148 | skip_install = true 149 | dependency_groups = ["ruff"] 150 | commands = [["ruff", "format", "--check", "--diff", "{posargs:.}"]] 151 | -------------------------------------------------------------------------------- /src/mopidy_mpris/playlists.py: -------------------------------------------------------------------------------- 1 | """Implementation of org.mpris.MediaPlayer2.Playlists interface. 2 | 3 | https://specifications.freedesktop.org/mpris/latest/Playlists_Interface.html 4 | """ 5 | 6 | # ruff: noqa: N802 7 | 8 | import base64 9 | import logging 10 | 11 | from mopidy.types import Uri 12 | from pydbus.generic import signal 13 | 14 | from mopidy_mpris.interface import Interface 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class Playlists(Interface): 20 | """ 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | """ 42 | 43 | INTERFACE = "org.mpris.MediaPlayer2.Playlists" 44 | 45 | def ActivatePlaylist(self, playlist_id: str) -> None: 46 | logger.debug("%s.ActivatePlaylist(%r) called", self.INTERFACE, playlist_id) 47 | playlist_uri = get_playlist_uri(playlist_id) 48 | playlist = self.core.playlists.lookup(playlist_uri).get() 49 | if playlist and playlist.tracks: 50 | tl_tracks = self.core.tracklist.add(playlist.tracks).get() 51 | self.core.playback.play(tlid=tl_tracks[0].tlid).get() 52 | 53 | def GetPlaylists( 54 | self, 55 | index: int, 56 | max_count: int, 57 | order: str, 58 | reverse: bool, # noqa: FBT001 59 | ) -> list[tuple[str, str, str]]: 60 | logger.debug( 61 | "%s.GetPlaylists(%r, %r, %r, %r) called", 62 | self.INTERFACE, 63 | index, 64 | max_count, 65 | order, 66 | reverse, 67 | ) 68 | playlists = self.core.playlists.as_list().get() 69 | if order == "Alphabetical": 70 | playlists.sort(key=lambda p: p.name or "", reverse=reverse) 71 | elif order == "User" and reverse: 72 | playlists.reverse() 73 | slice_end = index + max_count 74 | playlists = playlists[index:slice_end] 75 | return [(get_playlist_id(p.uri), p.name or "", "") for p in playlists] 76 | 77 | PlaylistChanged = signal() 78 | 79 | @property 80 | def PlaylistCount(self) -> int: 81 | self.log_trace("Getting %s.PlaylistCount", self.INTERFACE) 82 | return len(self.core.playlists.as_list().get()) 83 | 84 | @property 85 | def Orderings(self) -> list[str]: 86 | self.log_trace("Getting %s.Orderings", self.INTERFACE) 87 | return [ 88 | "Alphabetical", # Order by playlist.name 89 | "User", # Don't change order 90 | ] 91 | 92 | @property 93 | def ActivePlaylist(self) -> tuple[bool, tuple[str, str, str]]: 94 | self.log_trace("Getting %s.ActivePlaylist", self.INTERFACE) 95 | playlist_is_valid = False 96 | playlist = ("/", "None", "") 97 | return (playlist_is_valid, playlist) 98 | 99 | 100 | def get_playlist_id(playlist_uri: Uri) -> str: 101 | # Only A-Za-z0-9_ is allowed, which is 63 chars, so we can't use 102 | # base64. Luckily, D-Bus does not limit the length of object paths. 103 | # Since base32 pads trailing bytes with "=" chars, we need to replace 104 | # them with an allowed character such as "_". 105 | uri_bytes = str(playlist_uri).encode() 106 | encoded_uri = base64.b32encode(uri_bytes).decode().replace("=", "_") 107 | return f"/com/mopidy/playlist/{encoded_uri}" 108 | 109 | 110 | def get_playlist_uri(playlist_id: str | bytes) -> Uri: 111 | if isinstance(playlist_id, bytes): 112 | playlist_id = playlist_id.decode() 113 | encoded_uri = playlist_id.split("/")[-1].replace("_", "=").encode() 114 | return Uri(base64.b32decode(encoded_uri).decode()) 115 | -------------------------------------------------------------------------------- /tests/test_playlists.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | from mopidy.models import Playlist, Track 7 | from mopidy.types import PlaybackState 8 | 9 | from mopidy_mpris.playlists import Playlists 10 | 11 | if TYPE_CHECKING: 12 | from mopidy.config import Config 13 | from mopidy.core import CoreProxy 14 | 15 | 16 | @pytest.fixture 17 | def dummy_playlists(core: CoreProxy) -> dict[str, Playlist]: 18 | result = {} 19 | 20 | for name, lm in [("foo", 3000000), ("bar", 2000000), ("baz", 1000000)]: 21 | pl = core.playlists.create(name).get() 22 | assert pl 23 | pl = pl.replace(last_modified=lm) 24 | result[name] = core.playlists.save(pl).get() 25 | 26 | return result 27 | 28 | 29 | @pytest.fixture 30 | def playlists( 31 | config: Config, 32 | core: CoreProxy, 33 | dummy_playlists: dict[str, Playlist], 34 | ) -> Playlists: 35 | return Playlists(config, core) 36 | 37 | 38 | def test_activate_playlist_appends_tracks_to_tracklist( 39 | core: CoreProxy, 40 | playlists: Playlists, 41 | dummy_playlists: dict[str, Playlist], 42 | ): 43 | core.tracklist.add([Track(uri="dummy:old-a"), Track(uri="dummy:old-b")]) 44 | assert core.tracklist.get_length().get() == 2 45 | 46 | pl = dummy_playlists["baz"] 47 | pl = pl.replace( 48 | tracks=[ 49 | Track(uri="dummy:baz-a"), 50 | Track(uri="dummy:baz-b"), 51 | Track(uri="dummy:baz-c"), 52 | ] 53 | ) 54 | pl = core.playlists.save(pl).get() 55 | playlist_id = playlists.GetPlaylists(0, 100, "User", False)[2][0] 56 | 57 | playlists.ActivatePlaylist(playlist_id) 58 | 59 | assert core.tracklist.get_length().get() == 5 60 | assert core.playback.get_state().get() == PlaybackState.PLAYING 61 | assert core.playback.get_current_track().get() == pl.tracks[0] 62 | 63 | 64 | def test_activate_empty_playlist_is_harmless(core: CoreProxy, playlists: Playlists): 65 | assert core.tracklist.get_length().get() == 0 66 | playlist_id = playlists.GetPlaylists(0, 100, "User", False)[2][0] 67 | 68 | playlists.ActivatePlaylist(playlist_id) 69 | 70 | assert core.tracklist.get_length().get() == 0 71 | assert core.playback.get_state().get() == PlaybackState.STOPPED 72 | assert core.playback.get_current_track().get() is None 73 | 74 | 75 | def test_get_playlists_in_alphabetical_order(playlists: Playlists): 76 | result = playlists.GetPlaylists(0, 100, "Alphabetical", False) 77 | 78 | assert result == [ 79 | ("/com/mopidy/playlist/MR2W23LZHJRGC4Q_", "bar", ""), 80 | ("/com/mopidy/playlist/MR2W23LZHJRGC6Q_", "baz", ""), 81 | ("/com/mopidy/playlist/MR2W23LZHJTG63Y_", "foo", ""), 82 | ] 83 | 84 | 85 | def test_get_playlists_in_reverse_alphabetical_order(playlists: Playlists): 86 | result = playlists.GetPlaylists(0, 100, "Alphabetical", True) 87 | 88 | assert len(result) == 3 89 | assert result[0][1] == "foo" 90 | assert result[1][1] == "baz" 91 | assert result[2][1] == "bar" 92 | 93 | 94 | def test_get_playlists_in_user_order(playlists: Playlists): 95 | result = playlists.GetPlaylists(0, 100, "User", False) 96 | 97 | assert len(result) == 3 98 | assert result[0][1] == "foo" 99 | assert result[1][1] == "bar" 100 | assert result[2][1] == "baz" 101 | 102 | 103 | def test_get_playlists_in_reverse_user_order(playlists: Playlists): 104 | result = playlists.GetPlaylists(0, 100, "User", True) 105 | 106 | assert len(result) == 3 107 | assert result[0][1] == "baz" 108 | assert result[1][1] == "bar" 109 | assert result[2][1] == "foo" 110 | 111 | 112 | def test_get_playlists_slice_on_start_of_list(playlists: Playlists): 113 | result = playlists.GetPlaylists(0, 2, "User", False) 114 | 115 | assert len(result) == 2 116 | assert result[0][1] == "foo" 117 | assert result[1][1] == "bar" 118 | 119 | 120 | def test_get_playlists_slice_later_in_list(playlists: Playlists): 121 | result = playlists.GetPlaylists(2, 2, "User", False) 122 | 123 | assert len(result) == 1 124 | assert result[0][1] == "baz" 125 | 126 | 127 | def test_get_playlist_count_returns_number_of_playlists(playlists: Playlists): 128 | assert playlists.PlaylistCount == 3 129 | 130 | 131 | def test_get_orderings_includes_alpha_modified_and_user(playlists: Playlists): 132 | result = playlists.Orderings 133 | 134 | assert "Alphabetical" in result 135 | assert "Created" not in result 136 | assert "Modified" not in result 137 | assert "Played" not in result 138 | assert "User" in result 139 | 140 | 141 | def test_get_active_playlist_does_not_return_a_playlist(playlists: Playlists): 142 | result = playlists.ActivePlaylist 143 | 144 | valid, playlist = result 145 | playlist_id, playlist_name, playlist_icon_uri = playlist 146 | 147 | assert valid is False 148 | assert playlist_id == "/" 149 | assert playlist_name == "None" 150 | assert playlist_icon_uri == "" 151 | -------------------------------------------------------------------------------- /tests/dummy_backend.py: -------------------------------------------------------------------------------- 1 | """A dummy backend for use in tests. 2 | 3 | This backend implements the backend API in the simplest way possible. It is 4 | used in tests of the frontends. 5 | """ 6 | 7 | import pykka 8 | from mopidy import backend 9 | from mopidy.models import Playlist, Ref, SearchResult 10 | 11 | 12 | def create_proxy(config=None, audio=None): 13 | return DummyBackend.start(config=config, audio=audio).proxy() 14 | 15 | 16 | class DummyBackend(pykka.ThreadingActor, backend.Backend): 17 | def __init__(self, config, audio): 18 | super().__init__() 19 | 20 | self.library = DummyLibraryProvider(backend=self) 21 | if audio: 22 | self.playback = backend.PlaybackProvider(audio=audio, backend=self) 23 | else: 24 | self.playback = DummyPlaybackProvider(audio=audio, backend=self) 25 | self.playlists = DummyPlaylistsProvider(backend=self) 26 | 27 | self.uri_schemes = ["dummy"] 28 | 29 | 30 | class DummyLibraryProvider(backend.LibraryProvider): 31 | root_directory = Ref.directory(uri="dummy:/", name="dummy") 32 | 33 | def __init__(self, *args, **kwargs): 34 | super().__init__(*args, **kwargs) 35 | self.dummy_library = [] 36 | self.dummy_get_distinct_result = {} 37 | self.dummy_get_images_result = {} 38 | self.dummy_browse_result = {} 39 | self.dummy_find_exact_result = SearchResult() 40 | self.dummy_search_result = SearchResult() 41 | 42 | def browse(self, path): 43 | return self.dummy_browse_result.get(path, []) 44 | 45 | def get_distinct(self, field, query=None): 46 | return self.dummy_get_distinct_result.get(field, set()) 47 | 48 | def get_images(self, uris): 49 | return self.dummy_get_images_result 50 | 51 | def lookup(self, uri): 52 | uri = Ref.track(uri=uri).uri 53 | return [t for t in self.dummy_library if uri == t.uri] 54 | 55 | def refresh(self, uri=None): 56 | pass 57 | 58 | def search(self, query=None, uris=None, exact=False): # noqa: FBT002 59 | if exact: # TODO: remove uses of dummy_find_exact_result 60 | return self.dummy_find_exact_result 61 | return self.dummy_search_result 62 | 63 | 64 | class DummyPlaybackProvider(backend.PlaybackProvider): 65 | def __init__(self, *args, **kwargs): 66 | super().__init__(*args, **kwargs) 67 | self._uri = None 68 | self._time_position = 0 69 | 70 | def pause(self): 71 | return True 72 | 73 | def play(self): 74 | return self._uri and self._uri != "dummy:error" 75 | 76 | def change_track(self, track): 77 | """Pass a track with URI 'dummy:error' to force failure""" 78 | self._uri = track.uri 79 | self._time_position = 0 80 | return True 81 | 82 | def prepare_change(self): 83 | pass 84 | 85 | def resume(self): 86 | return True 87 | 88 | def seek(self, time_position): 89 | self._time_position = time_position 90 | return True 91 | 92 | def stop(self): 93 | self._uri = None 94 | return True 95 | 96 | def get_time_position(self): 97 | return self._time_position 98 | 99 | 100 | class DummyPlaylistsProvider(backend.PlaylistsProvider): 101 | def __init__(self, backend): 102 | super().__init__(backend) 103 | self._playlists = [] 104 | self._allow_save = True 105 | 106 | def set_dummy_playlists(self, playlists): 107 | """For tests using the dummy provider through an actor proxy.""" 108 | self._playlists = playlists 109 | 110 | def set_allow_save(self, enabled): 111 | self._allow_save = enabled 112 | 113 | def as_list(self): 114 | return [Ref.playlist(uri=pl.uri, name=pl.name) for pl in self._playlists] 115 | 116 | def get_items(self, uri): 117 | playlist = self.lookup(uri) 118 | if playlist is None: 119 | return None 120 | return [Ref.track(uri=t.uri, name=t.name) for t in playlist.tracks] 121 | 122 | def lookup(self, uri): 123 | uri = Ref.playlist(uri=uri).uri 124 | for playlist in self._playlists: 125 | if playlist.uri == uri: 126 | return playlist 127 | return None 128 | 129 | def refresh(self): 130 | pass 131 | 132 | def create(self, name): 133 | playlist = Playlist(name=name, uri=f"dummy:{name}") 134 | self._playlists.append(playlist) 135 | return playlist 136 | 137 | def delete(self, uri): 138 | playlist = self.lookup(uri) 139 | if playlist: 140 | self._playlists.remove(playlist) 141 | 142 | def save(self, playlist): 143 | if not self._allow_save: 144 | return None 145 | 146 | old_playlist = self.lookup(playlist.uri) 147 | 148 | if old_playlist is not None: 149 | index = self._playlists.index(old_playlist) 150 | self._playlists[index] = playlist 151 | else: 152 | self._playlists.append(playlist) 153 | 154 | return playlist 155 | -------------------------------------------------------------------------------- /src/mopidy_mpris/frontend.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import override 3 | 4 | import pykka 5 | from mopidy.config import Config 6 | from mopidy.core import CoreEvent, CoreEventData, CoreListener, CoreProxy 7 | from mopidy.models import Playlist, TlTrack 8 | from mopidy.types import DurationMs, Percentage, PlaybackState, Uri 9 | 10 | from mopidy_mpris.interface import Interface 11 | from mopidy_mpris.playlists import get_playlist_id 12 | from mopidy_mpris.server import Server 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class MprisFrontend(pykka.ThreadingActor, CoreListener): 18 | @override 19 | def __init__(self, config: Config, core: CoreProxy) -> None: 20 | super().__init__() 21 | self.config = config 22 | self.core = core 23 | self.mpris: Server | None = None 24 | 25 | @override 26 | def on_start(self) -> None: 27 | try: 28 | self.mpris = Server(self.config, self.core) 29 | self.mpris.publish() 30 | except Exception as e: # noqa: BLE001 31 | logger.warning("MPRIS frontend setup failed (%s)", e) 32 | self.stop() 33 | 34 | @override 35 | def on_stop(self) -> None: 36 | logger.debug("Removing MPRIS object from D-Bus connection...") 37 | if self.mpris: 38 | self.mpris.unpublish() 39 | self.mpris = None 40 | logger.debug("Removed MPRIS object from D-Bus connection") 41 | 42 | @override 43 | def on_event(self, event: CoreEvent, **kwargs: CoreEventData) -> None: 44 | logger.debug("Received %s event", event) 45 | if self.mpris is None: 46 | return None 47 | return super().on_event(event, **kwargs) 48 | 49 | @override 50 | def track_playback_paused( 51 | self, 52 | tl_track: TlTrack, 53 | time_position: DurationMs, 54 | ) -> None: 55 | assert self.mpris 56 | _emit_properties_changed(self.mpris.player, ["PlaybackStatus"]) 57 | 58 | @override 59 | def track_playback_resumed( 60 | self, 61 | tl_track: TlTrack, 62 | time_position: DurationMs, 63 | ) -> None: 64 | assert self.mpris 65 | _emit_properties_changed(self.mpris.player, ["PlaybackStatus"]) 66 | 67 | @override 68 | def track_playback_started(self, tl_track: TlTrack) -> None: 69 | assert self.mpris 70 | _emit_properties_changed(self.mpris.player, ["PlaybackStatus", "Metadata"]) 71 | 72 | @override 73 | def track_playback_ended( 74 | self, 75 | tl_track: TlTrack, 76 | time_position: DurationMs, 77 | ) -> None: 78 | assert self.mpris 79 | _emit_properties_changed(self.mpris.player, ["PlaybackStatus", "Metadata"]) 80 | 81 | @override 82 | def playback_state_changed( 83 | self, 84 | old_state: PlaybackState, 85 | new_state: PlaybackState, 86 | ) -> None: 87 | assert self.mpris 88 | _emit_properties_changed(self.mpris.player, ["PlaybackStatus", "Metadata"]) 89 | 90 | @override 91 | def tracklist_changed(self) -> None: 92 | pass # TODO: Implement if adding tracklist support 93 | 94 | @override 95 | def playlists_loaded(self) -> None: 96 | assert self.mpris 97 | _emit_properties_changed(self.mpris.playlists, ["PlaylistCount"]) 98 | 99 | @override 100 | def playlist_changed(self, playlist: Playlist) -> None: 101 | assert self.mpris 102 | if playlist.uri is None: 103 | return 104 | playlist_id = get_playlist_id(playlist.uri) 105 | self.mpris.playlists.PlaylistChanged(playlist_id, playlist.name, "") # pyright: ignore[reportCallIssue] 106 | 107 | @override 108 | def playlist_deleted(self, uri: Uri) -> None: 109 | assert self.mpris 110 | _emit_properties_changed(self.mpris.playlists, ["PlaylistCount"]) 111 | 112 | @override 113 | def options_changed(self) -> None: 114 | assert self.mpris 115 | _emit_properties_changed( 116 | self.mpris.player, 117 | ["LoopStatus", "Shuffle", "CanGoPrevious", "CanGoNext"], 118 | ) 119 | 120 | @override 121 | def volume_changed(self, volume: Percentage) -> None: 122 | assert self.mpris 123 | _emit_properties_changed(self.mpris.player, ["Volume"]) 124 | 125 | @override 126 | def mute_changed(self, mute: bool) -> None: 127 | assert self.mpris 128 | _emit_properties_changed(self.mpris.player, ["Volume"]) 129 | 130 | @override 131 | def seeked(self, time_position: DurationMs) -> None: 132 | assert self.mpris 133 | time_position_in_microseconds = time_position * 1000 134 | self.mpris.player.Seeked(time_position_in_microseconds) # pyright: ignore[reportCallIssue] 135 | 136 | @override 137 | def stream_title_changed(self, title: str) -> None: 138 | assert self.mpris 139 | _emit_properties_changed(self.mpris.player, ["Metadata"]) 140 | 141 | 142 | def _emit_properties_changed( 143 | interface: Interface, changed_properties: list[str] 144 | ) -> None: 145 | props_with_new_values = [(p, getattr(interface, p)) for p in changed_properties] 146 | interface.PropertiesChanged( # pyright: ignore[reportCallIssue] 147 | interface.INTERFACE, 148 | dict(props_with_new_values), 149 | [], 150 | ) 151 | -------------------------------------------------------------------------------- /tests/test_events.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | from mopidy.models import Playlist, TlTrack, Track 5 | from mopidy.types import PlaybackState, TracklistId 6 | 7 | from mopidy_mpris import player, playlists, root, server 8 | from mopidy_mpris.frontend import MprisFrontend 9 | 10 | 11 | @pytest.fixture 12 | def frontend() -> MprisFrontend: 13 | # As a plain class, not an actor: 14 | result = MprisFrontend(config=None, core=None) 15 | result.mpris = mock.Mock(spec=server.Server) 16 | result.mpris.root = mock.Mock(spec=root.Root) 17 | result.mpris.root.INTERFACE = root.Root.INTERFACE 18 | result.mpris.player = mock.Mock(spec=player.Player) 19 | result.mpris.player.INTERFACE = player.Player.INTERFACE 20 | result.mpris.playlists = mock.Mock(spec=playlists.Playlists) 21 | result.mpris.playlists.INTERFACE = playlists.Playlists.INTERFACE 22 | return result 23 | 24 | 25 | def test_track_playback_paused_event_changes_playback_status(frontend: MprisFrontend): 26 | frontend.mpris.player.PlaybackStatus = "Paused" 27 | 28 | frontend.track_playback_paused( 29 | tl_track=TlTrack(tlid=TracklistId(1), track=Track()), 30 | time_position=0, 31 | ) 32 | 33 | frontend.mpris.player.PropertiesChanged.assert_called_with( 34 | player.Player.INTERFACE, {"PlaybackStatus": "Paused"}, [] 35 | ) 36 | 37 | 38 | def test_track_playback_resumed_event_changes_playback_status(frontend: MprisFrontend): 39 | frontend.mpris.player.PlaybackStatus = "Playing" 40 | 41 | frontend.track_playback_resumed( 42 | tl_track=TlTrack(tlid=TracklistId(1), track=Track()), 43 | time_position=0, 44 | ) 45 | 46 | frontend.mpris.player.PropertiesChanged.assert_called_with( 47 | player.Player.INTERFACE, {"PlaybackStatus": "Playing"}, [] 48 | ) 49 | 50 | 51 | def test_track_playback_started_changes_playback_status_and_metadata( 52 | frontend: MprisFrontend, 53 | ): 54 | frontend.mpris.player.Metadata = "..." 55 | frontend.mpris.player.PlaybackStatus = "Playing" 56 | 57 | frontend.track_playback_started( 58 | tl_track=TlTrack(tlid=TracklistId(1), track=Track()) 59 | ) 60 | 61 | frontend.mpris.player.PropertiesChanged.assert_called_with( 62 | player.Player.INTERFACE, 63 | {"Metadata": "...", "PlaybackStatus": "Playing"}, 64 | [], 65 | ) 66 | 67 | 68 | def test_track_playback_ended_changes_playback_status_and_metadata( 69 | frontend: MprisFrontend, 70 | ): 71 | frontend.mpris.player.Metadata = "..." 72 | frontend.mpris.player.PlaybackStatus = "Stopped" 73 | 74 | frontend.track_playback_ended( 75 | tl_track=TlTrack(tlid=TracklistId(1), track=Track()), 76 | time_position=0, 77 | ) 78 | 79 | frontend.mpris.player.PropertiesChanged.assert_called_with( 80 | player.Player.INTERFACE, 81 | {"Metadata": "...", "PlaybackStatus": "Stopped"}, 82 | [], 83 | ) 84 | 85 | 86 | def test_playback_state_changed_changes_playback_status_and_metadata( 87 | frontend: MprisFrontend, 88 | ): 89 | frontend.mpris.player.Metadata = "..." 90 | frontend.mpris.player.PlaybackStatus = "Stopped" 91 | 92 | frontend.playback_state_changed(PlaybackState.PLAYING, PlaybackState.STOPPED) 93 | 94 | frontend.mpris.player.PropertiesChanged.assert_called_with( 95 | player.Player.INTERFACE, 96 | {"Metadata": "...", "PlaybackStatus": "Stopped"}, 97 | [], 98 | ) 99 | 100 | 101 | def test_playlists_loaded_event_changes_playlist_count(frontend: MprisFrontend): 102 | frontend.mpris.playlists.PlaylistCount = 17 103 | 104 | frontend.playlists_loaded() 105 | 106 | frontend.mpris.playlists.PropertiesChanged.assert_called_with( 107 | playlists.Playlists.INTERFACE, {"PlaylistCount": 17}, [] 108 | ) 109 | 110 | 111 | def test_playlist_changed_event_causes_mpris_playlist_changed_event( 112 | frontend: MprisFrontend, 113 | ): 114 | playlist = Playlist(uri="dummy:foo", name="foo") 115 | 116 | frontend.playlist_changed(playlist=playlist) 117 | 118 | frontend.mpris.playlists.PlaylistChanged.assert_called_with( 119 | "/com/mopidy/playlist/MR2W23LZHJTG63Y_", "foo", "" 120 | ) 121 | 122 | 123 | def test_playlist_deleted_event_changes_playlist_count(frontend: MprisFrontend): 124 | frontend.mpris.playlists.PlaylistCount = 17 125 | 126 | frontend.playlist_deleted("dummy:foo") 127 | 128 | frontend.mpris.playlists.PropertiesChanged.assert_called_with( 129 | playlists.Playlists.INTERFACE, {"PlaylistCount": 17}, [] 130 | ) 131 | 132 | 133 | def test_options_changed_event_changes_loopstatus_and_shuffle(frontend: MprisFrontend): 134 | frontend.mpris.player.CanGoPrevious = False 135 | frontend.mpris.player.CanGoNext = True 136 | frontend.mpris.player.LoopStatus = "Track" 137 | frontend.mpris.player.Shuffle = True 138 | 139 | frontend.options_changed() 140 | 141 | frontend.mpris.player.PropertiesChanged.assert_called_with( 142 | player.Player.INTERFACE, 143 | { 144 | "LoopStatus": "Track", 145 | "Shuffle": True, 146 | "CanGoPrevious": False, 147 | "CanGoNext": True, 148 | }, 149 | [], 150 | ) 151 | 152 | 153 | def test_volume_changed_event_changes_volume(frontend: MprisFrontend): 154 | frontend.mpris.player.Volume = 1.0 155 | 156 | frontend.volume_changed(volume=100) 157 | 158 | frontend.mpris.player.PropertiesChanged.assert_called_with( 159 | player.Player.INTERFACE, {"Volume": 1.0}, [] 160 | ) 161 | 162 | 163 | def test_mute_changed_event_changes_volume(frontend: MprisFrontend): 164 | frontend.mpris.player.Volume = 0.0 165 | 166 | frontend.mute_changed(True) 167 | 168 | frontend.mpris.player.PropertiesChanged.assert_called_with( 169 | player.Player.INTERFACE, {"Volume": 0.0}, [] 170 | ) 171 | 172 | 173 | def test_seeked_event_causes_mpris_seeked_event(frontend: MprisFrontend): 174 | frontend.seeked(time_position=31000) 175 | 176 | frontend.mpris.player.Seeked.assert_called_with(31000000) 177 | 178 | 179 | def test_stream_title_changed_changes_metadata(frontend: MprisFrontend): 180 | frontend.mpris.player.Metadata = "..." 181 | 182 | frontend.stream_title_changed("a new title") 183 | 184 | frontend.mpris.player.PropertiesChanged.assert_called_with( 185 | player.Player.INTERFACE, {"Metadata": "..."}, [] 186 | ) 187 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mopidy-mpris 2 | 3 | [![Latest PyPI version](https://img.shields.io/pypi/v/mopidy-mpris)](https://pypi.org/p/mopidy-mpris) 4 | [![CI build status](https://img.shields.io/github/actions/workflow/status/mopidy/mopidy-mpris/ci.yml)](https://github.com/mopidy/mopidy-mpris/actions/workflows/ci.yml) 5 | [![Test coverage](https://img.shields.io/codecov/c/gh/mopidy/mopidy-mpris)](https://codecov.io/gh/mopidy/mopidy-mpris) 6 | 7 | [Mopidy](https://mopidy.com/) extension for controlling Mopidy through the MPRIS 8 | D-Bus interface. 9 | 10 | Mopidy-MPRIS supports the minimum requirements of the [MPRIS specification][1] 11 | as well as the optional [Playlists interface][2]. The [TrackList interface][3] 12 | is currently not supported. 13 | 14 | [1]: https://specifications.freedesktop.org/mpris-spec/latest/ 15 | [2]: https://specifications.freedesktop.org/mpris-spec/latest/Playlists_Interface.html 16 | [3]: https://specifications.freedesktop.org/mpris-spec/latest/Track_List_Interface.html 17 | 18 | 19 | ## Maintainer wanted 20 | 21 | Mopidy-MPRIS is currently kept on life support by the Mopidy core developers. 22 | It is in need of a more dedicated maintainer. 23 | 24 | If you want to be the maintainer of Mopidy-MPRIS, please: 25 | 26 | 1. Make 2-3 good pull requests improving any part of the project. 27 | 28 | 2. Read and get familiar with all of the project's open issues. 29 | 30 | 3. Send a pull request removing this section and adding yourself as the 31 | "Current maintainer" in the "Credits" section below. In the pull request 32 | description, please refer to the previous pull requests and state that 33 | you've familiarized yourself with the open issues. 34 | 35 | As a maintainer, you'll be given push access to the repo and the authority to 36 | make releases to PyPI when you see fit. 37 | 38 | 39 | ## Installation 40 | 41 | Install by running: 42 | 43 | ```sh 44 | python3 -m pip install mopidy-mpris 45 | ``` 46 | 47 | See https://mopidy.com/ext/mpris/ for alternative installation methods. 48 | 49 | 50 | ## Configuration 51 | 52 | No configuration is required for the MPRIS extension to work. 53 | 54 | The following configuration values are available: 55 | 56 | - `mpris/enabled`: If the MPRIS extension should be enabled or not. 57 | Defaults to ``true``. 58 | 59 | - `mpris/bus_type`: The type of D-Bus bus Mopidy-MPRIS should connect to. 60 | Choices include `session` (the default) and `system`. 61 | 62 | 63 | ## Usage 64 | 65 | Once Mopidy-MPRIS has been installed and your Mopidy server has been 66 | restarted, the Mopidy-MPRIS extension announces its presence on D-Bus so that 67 | any MPRIS compatible clients on your system can interact with it. Exactly how 68 | you control Mopidy through MPRIS depends on which MPRIS client you use. 69 | 70 | 71 | ## Clients 72 | 73 | The following clients have been tested with Mopidy-MPRIS. 74 | 75 | ### GNOME Shell builtin 76 | 77 | - State: Not working 78 | - Tested versions: 79 | - Ubuntu 18.10, 80 | - GNOME Shell 3.30.1-2ubuntu1, 81 | - Mopidy-MPRIS 2.0.0 82 | 83 | GNOME Shell, which is the default desktop on Ubuntu 18.04 onwards, has a 84 | builtin MPRIS client. This client seems to work well with Spotify's player, 85 | but Mopidy-MPRIS does not show up here. 86 | 87 | If you have any tips on what's missing to get this working, please open an 88 | issue. 89 | 90 | ### gnome-shell-extensions-mediaplayer 91 | 92 | - State: Working 93 | - Tested versions: 94 | - Ubuntu 18.10, 95 | - GNOME Shell 3.30.1-2ubuntu1, 96 | - gnome-shell-extension-mediaplayer 63, 97 | - Mopidy-MPRIS 2.0.0 98 | - Website: https://github.com/JasonLG1979/gnome-shell-extensions-mediaplayer 99 | 100 | gnome-shell-extensions-mediaplayer is a quite feature rich MPRIS client 101 | built as an extension to GNOME Shell. With the improvements to Mopidy-MPRIS 102 | in v2.0, this extension works very well with Mopidy. 103 | 104 | ### gnome-shell-extensions-mpris-indicator-button 105 | 106 | - State: Working 107 | - Tested versions: 108 | - Ubuntu 18.10, 109 | - GNOME Shell 3.30.1-2ubuntu1, 110 | - gnome-shell-extensions-mpris-indicator-button 5, 111 | - Mopidy-MPRIS 2.0.0 112 | - Website: 113 | https://github.com/JasonLG1979/gnome-shell-extensions-mpris-indicator-button/ 114 | 115 | gnome-shell-extensions-mpris-indicator-button is a minimalistic version of 116 | gnome-shell-extensions-mediaplayer. It works with Mopidy-MPRIS, with the 117 | exception of the play/pause button not changing state when Mopidy starts 118 | playing. 119 | 120 | If you have any tips on what's missing to get the play/pause button display 121 | correctly, please open an issue. 122 | 123 | ### Ubuntu Sound Menu 124 | 125 | - State: Unknown 126 | 127 | Historically, Ubuntu Sound Menu was the primary target for Mopidy-MPRIS' 128 | development. Since Ubuntu 18.04 replaced Unity with GNOME Shell, this is no 129 | longer the case. It is currently unknown to what degree Mopidy-MPRIS works 130 | with old Ubuntu setups. 131 | 132 | If you run an Ubuntu setup with Unity and have tested Mopidy-MPRIS, please 133 | open an issue to share your results. 134 | 135 | 136 | ## Advanced setups 137 | 138 | ### Running as a service 139 | 140 | If you have input on how to best configure Mopidy-MPRIS when Mopidy is 141 | running as a service, please add a comment to 142 | [issue #15](https://github.com/mopidy/mopidy-mpris/issues/15). 143 | 144 | ### MPRIS on the system bus 145 | 146 | You can set the `mpris/bus_type` config value to `system`. This will lead 147 | to Mopidy-MPRIS making itself available on the system bus instead of the 148 | logged in user's session bus. 149 | 150 | > [!NOTE] 151 | > Few MPRIS clients will try to access MPRIS devices on the system bus, so 152 | > this will give you limited functionality. For example, media keys in 153 | > GNOME Shell does not work with media players that expose their MPRIS 154 | > interface on the system bus instead of the user's session bus. 155 | 156 | The default setup will often not permit Mopidy to publish its service on the 157 | D-Bus system bus, causing a warning similar to this in Mopidy's log: 158 | 159 | ``` 160 | MPRIS frontend setup failed (g-dbus-error-quark: 161 | GDBus.Error:org.freedesktop.DBus.Error.AccessDenied: Connection ":1.3071" 162 | is not allowed to own the service "org.mpris.MediaPlayer2.mopidy" due to 163 | security policies in the configuration file (9)) 164 | ``` 165 | 166 | To solve this, create the file 167 | `/etc/dbus-1/system.d/org.mpris.MediaPlayer2.mopidy.conf` with the 168 | following contents: 169 | 170 | ```xml 171 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | ``` 186 | 187 | If you run Mopidy as another user than `mopidy`, you must 188 | update `user="mopidy"` in the above file accordingly. 189 | 190 | Once the file is in place, you must restart Mopidy for the change to take 191 | effect. 192 | 193 | To test the setup, you can run the following command as any user on the 194 | system to play/pause the music: 195 | 196 | ```sh 197 | dbus-send --system --print-reply \ 198 | --dest=org.mpris.MediaPlayer2.mopidy \ 199 | /org/mpris/MediaPlayer2 \ 200 | org.mpris.MediaPlayer2.Player.PlayPause 201 | ``` 202 | 203 | ### UPnP/DLNA with Rygel 204 | 205 | [Rygel](https://wiki.gnome.org/Projects/Rygel) is an application that will 206 | translate between Mopidy's MPRIS interface and UPnP. Rygel must be run on the 207 | same machine as Mopidy, but will make Mopidy controllable by any device on the 208 | local network that can control a UPnP/DLNA MediaRenderer. 209 | 210 | The setup process is approximately as follows: 211 | 212 | 1. Install Rygel. 213 | 214 | On Debian/Ubuntu: 215 | 216 | ```sh 217 | sudo apt install rygel 218 | ``` 219 | 220 | 2. Enable Rygel's MPRIS plugin. 221 | 222 | On Debian/Ubuntu, edit `/etc/rygel.conf`, find the `[MPRIS]` 223 | section, and change `enabled=false` to `enabled=true`. 224 | 225 | 3. Start Rygel. 226 | 227 | To start it as the current user:: 228 | 229 | ```sh 230 | systemctl --user start rygel 231 | ``` 232 | 233 | To make Rygel start as the current user on boot:: 234 | 235 | ```sh 236 | systemctl --user enable rygel 237 | ``` 238 | 239 | 4. Configure your system's firewall to allow the local network to reach 240 | Rygel. Exactly how is out of scope for this document. 241 | 242 | 5. Start Mopidy with Mopidy-MPRIS enabled. 243 | 244 | 6. If you view Rygel's log output with:: 245 | 246 | ```sh 247 | journalctl --user -feu rygel 248 | ``` 249 | 250 | You should see a log statement similar to:: 251 | 252 | ``` 253 | New plugin "org.mpris.MediaPlayer2.mopidy" available 254 | ``` 255 | 256 | 6. If everything went well, you should now be able to control Mopidy from a 257 | device on your local network that can control an UPnP/DLNA MediaRenderer, 258 | for example the Android app BubbleUPnP. 259 | 260 | Alternatively, [upmpdcli combined with 261 | Mopidy-MPD](https://docs.mopidy.com/en/latest/clients/upnp/) serves the same 262 | purpose as this setup. 263 | 264 | 265 | 266 | ## Project resources 267 | 268 | - [Source code](https://github.com/mopidy/mopidy-mpris) 269 | - [Issues](https://github.com/mopidy/mopidy-mpris/issues) 270 | - [Releases](https://github.com/mopidy/mopidy-mpris/releases) 271 | 272 | 273 | ## Development 274 | 275 | ### Set up development environment 276 | 277 | Clone the repo using, e.g. using [gh](https://cli.github.com/): 278 | 279 | ```sh 280 | gh repo clone mopidy/mopidy-mpris 281 | ``` 282 | 283 | Enter the directory, and install dependencies using [uv](https://docs.astral.sh/uv/): 284 | 285 | ```sh 286 | cd mopidy-mpris/ 287 | uv sync 288 | ``` 289 | 290 | ### Running tests 291 | 292 | To run all tests and linters in isolated environments, use 293 | [tox](https://tox.wiki/): 294 | 295 | ```sh 296 | tox 297 | ``` 298 | 299 | To only run tests, use [pytest](https://pytest.org/): 300 | 301 | ```sh 302 | pytest 303 | ``` 304 | 305 | To format the code, use [ruff](https://docs.astral.sh/ruff/): 306 | 307 | ```sh 308 | ruff format . 309 | ``` 310 | 311 | To check for lints with ruff, run: 312 | 313 | ```sh 314 | ruff check . 315 | ``` 316 | 317 | To check for type errors, use [pyright](https://microsoft.github.io/pyright/): 318 | 319 | ```sh 320 | pyright . 321 | ``` 322 | 323 | ### Adding features and fixing bugs 324 | 325 | Mopidy-MPRIS has an extensive test suite, so the first step for all changes 326 | or additions is to add a test exercising the new code. However, making the 327 | tests pass doesn't ensure that what comes out on the D-Bus bus is correct. To 328 | introspect this through the bus, there's a couple of useful tools. 329 | 330 | ### Browsing the MPRIS API with D-Feet 331 | 332 | D-Feet is a graphical D-Bus browser. On Debian/Ubuntu systems it can be 333 | installed by running: 334 | 335 | ```sh 336 | sudo apt install d-feet 337 | ``` 338 | 339 | Then run the `d-feet` command. In the D-Feet window, select the tab 340 | corresponding to the bus you run Mopidy-MPRIS on, usually the session bus. 341 | Then search for "MediaPlayer2" to find all available MPRIS interfaces. 342 | 343 | To get the current value of a property, double-click it. To execute a method, 344 | double-click it, provide any required arguments, and click "Execute". 345 | 346 | For more information on D-Feet, see the 347 | [GNOME wiki](https://wiki.gnome.org/Apps/DFeet). 348 | 349 | ### Testing the MPRIS API with pydbus 350 | 351 | To use the MPRIS API directly, start Mopidy, and then run the following in a 352 | Python shell to use `pydbus` as an MPRIS client: 353 | 354 | ```pycon 355 | >>> import pydbus 356 | >>> bus = pydbus.SessionBus() 357 | >>> player = bus.get('org.mpris.MediaPlayer2.mopidy', '/org/mpris/MediaPlayer2') 358 | ``` 359 | 360 | Now you can control Mopidy through the player object. To get properties from 361 | Mopidy, run for example: 362 | 363 | ```pycon 364 | >>> player.PlaybackStatus 365 | 'Playing' 366 | >>> player.Metadata 367 | {'mpris:artUrl': 'https://i.scdn.co/image/8eb49b41eeb45c1cf53e1ddfea7973d9ca257777', 368 | 'mpris:length': 342000000, 369 | 'mpris:trackid': '/com/mopidy/track/36', 370 | 'xesam:album': '65/Milo', 371 | 'xesam:albumArtist': ['Kiasmos'], 372 | 'xesam:artist': ['Rival Consoles'], 373 | 'xesam:discNumber': 1, 374 | 'xesam:title': 'Arp', 375 | 'xesam:trackNumber': 5, 376 | 'xesam:url': 'spotify:track:7CoxEEsqo3XdvUsScRV4WD'} 377 | >>> 378 | ``` 379 | 380 | To pause Mopidy's playback through D-Bus, run: 381 | 382 | ```pycon 383 | >>> player.Pause() 384 | >>> 385 | ``` 386 | 387 | For details on the API, please refer to the 388 | [MPRIS specification](https://specifications.freedesktop.org/mpris-spec/latest/). 389 | 390 | ### Making a release 391 | 392 | To make a release to PyPI, go to the project's [GitHub releases 393 | page](https://github.com/mopidy/mopidy-mpris/releases) 394 | and click the "Draft a new release" button. 395 | 396 | In the "choose a tag" dropdown, select the tag you want to release or create a 397 | new tag, e.g. `v0.1.0`. Add a title, e.g. `v0.1.0`, and a description of the changes. 398 | 399 | Decide if the release is a pre-release (alpha, beta, or release candidate) or 400 | should be marked as the latest release, and click "Publish release". 401 | 402 | Once the release is created, the `release.yml` GitHub Action will automatically 403 | build and publish the release to 404 | [PyPI](https://pypi.org/project/mopidy-mpris/). 405 | 406 | 407 | ## Credits 408 | 409 | - Original author: [Stein Magnus Jodal](https://github.com/mopidy) 410 | - Current maintainer: None. Maintainer wanted, see section above. 411 | - [Contributors](https://github.com/mopidy/mopidy-mpris/graphs/contributors) 412 | -------------------------------------------------------------------------------- /src/mopidy_mpris/player.py: -------------------------------------------------------------------------------- 1 | """Implementation of org.mpris.MediaPlayer2.Player interface. 2 | 3 | https://specifications.freedesktop.org/mpris/latest/Player_Interface.html 4 | """ 5 | 6 | # ruff: noqa: N802 7 | 8 | import logging 9 | from typing import Literal 10 | 11 | from gi.repository.GLib import ( # pyright: ignore[reportMissingImports, reportMissingModuleSource] 12 | Variant, 13 | ) 14 | from mopidy.models import Track 15 | from mopidy.types import DurationMs, Percentage, PlaybackState, TracklistId, Uri 16 | from pydbus.generic import signal 17 | 18 | from mopidy_mpris.interface import Interface 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class Player(Interface): 24 | """ 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | """ 64 | 65 | INTERFACE = "org.mpris.MediaPlayer2.Player" 66 | 67 | # To override from tests. 68 | _CanControl = True 69 | 70 | def Next(self) -> None: 71 | logger.debug("%s.Next called", self.INTERFACE) 72 | if not self.CanGoNext: 73 | logger.debug("%s.Next not allowed", self.INTERFACE) 74 | return 75 | self.core.playback.next().get() 76 | 77 | def Previous(self) -> None: 78 | logger.debug("%s.Previous called", self.INTERFACE) 79 | if not self.CanGoPrevious: 80 | logger.debug("%s.Previous not allowed", self.INTERFACE) 81 | return 82 | self.core.playback.previous().get() 83 | 84 | def Pause(self) -> None: 85 | logger.debug("%s.Pause called", self.INTERFACE) 86 | if not self.CanPause: 87 | logger.debug("%s.Pause not allowed", self.INTERFACE) 88 | return 89 | self.core.playback.pause().get() 90 | 91 | def PlayPause(self) -> None: 92 | logger.debug("%s.PlayPause called", self.INTERFACE) 93 | if not self.CanPause: 94 | logger.debug("%s.PlayPause not allowed", self.INTERFACE) 95 | return 96 | state = self.core.playback.get_state().get() 97 | if state == PlaybackState.PLAYING: 98 | self.core.playback.pause().get() 99 | elif state == PlaybackState.PAUSED: 100 | self.core.playback.resume().get() 101 | elif state == PlaybackState.STOPPED: 102 | self.core.playback.play().get() 103 | 104 | def Stop(self) -> None: 105 | logger.debug("%s.Stop called", self.INTERFACE) 106 | if not self.CanControl: 107 | logger.debug("%s.Stop not allowed", self.INTERFACE) 108 | return 109 | self.core.playback.stop().get() 110 | 111 | def Play(self) -> None: 112 | logger.debug("%s.Play called", self.INTERFACE) 113 | if not self.CanPlay: 114 | logger.debug("%s.Play not allowed", self.INTERFACE) 115 | return 116 | state = self.core.playback.get_state().get() 117 | if state == PlaybackState.PAUSED: 118 | self.core.playback.resume().get() 119 | else: 120 | self.core.playback.play().get() 121 | 122 | def Seek(self, offset: int) -> None: 123 | logger.debug("%s.Seek called", self.INTERFACE) 124 | if not self.CanSeek: 125 | logger.debug("%s.Seek not allowed", self.INTERFACE) 126 | return 127 | offset_ms = offset // 1000 128 | current_position = self.core.playback.get_time_position().get() 129 | new_position = current_position + offset_ms 130 | new_position = DurationMs(max(new_position, 0)) 131 | self.core.playback.seek(new_position).get() 132 | 133 | def SetPosition(self, track_id: str, position: int) -> None: 134 | logger.debug("%s.SetPosition called", self.INTERFACE) 135 | if not self.CanSeek: 136 | logger.debug("%s.SetPosition not allowed", self.INTERFACE) 137 | return 138 | position_ms = DurationMs(position // 1000) 139 | current_tl_track = self.core.playback.get_current_tl_track().get() 140 | if current_tl_track is None: 141 | return 142 | if track_id != get_track_id(current_tl_track.tlid): 143 | return 144 | if position_ms < 0: 145 | return 146 | if ( 147 | current_tl_track.track.length is None 148 | or current_tl_track.track.length < position_ms 149 | ): 150 | return 151 | self.core.playback.seek(position_ms).get() 152 | 153 | def OpenUri(self, uri: str) -> None: 154 | logger.debug("%s.OpenUri called", self.INTERFACE) 155 | if not self.CanControl: 156 | # NOTE The spec does not explicitly require this check, but 157 | # guarding the other methods doesn't help much if OpenUri is open 158 | # for use. 159 | logger.debug("%s.OpenUri not allowed", self.INTERFACE) 160 | return 161 | # NOTE Check if URI has MIME type known to the backend, if MIME support 162 | # is added to the backend. 163 | tl_tracks = self.core.tracklist.add(uris=[Uri(uri)]).get() 164 | if tl_tracks: 165 | self.core.playback.play(tlid=tl_tracks[0].tlid).get() 166 | else: 167 | logger.debug('Track with URI "%s" not found in library.', uri) 168 | 169 | Seeked = signal() 170 | 171 | @property 172 | def PlaybackStatus(self) -> Literal["Playing", "Paused", "Stopped"]: 173 | self.log_trace("Getting %s.PlaybackStatus", self.INTERFACE) 174 | state = self.core.playback.get_state().get() 175 | match state: 176 | case PlaybackState.PLAYING: 177 | return "Playing" 178 | case PlaybackState.PAUSED: 179 | return "Paused" 180 | case PlaybackState.STOPPED: 181 | return "Stopped" 182 | 183 | @property 184 | def LoopStatus(self) -> Literal["None", "Track", "Playlist"]: 185 | self.log_trace("Getting %s.LoopStatus", self.INTERFACE) 186 | repeat = self.core.tracklist.get_repeat().get() 187 | single = self.core.tracklist.get_single().get() 188 | match (repeat, single): 189 | case (False, _): 190 | return "None" 191 | case (True, True): 192 | return "Track" 193 | case (True, False): 194 | return "Playlist" 195 | 196 | @LoopStatus.setter 197 | def LoopStatus(self, value: Literal["None", "Track", "Playlist"]) -> None: 198 | if not self.CanControl: 199 | logger.debug("Setting %s.LoopStatus not allowed", self.INTERFACE) 200 | return 201 | logger.debug("Setting %s.LoopStatus to %s", self.INTERFACE, value) 202 | match value: 203 | case "None": 204 | self.core.tracklist.set_repeat(False) 205 | self.core.tracklist.set_single(False) 206 | case "Track": 207 | self.core.tracklist.set_repeat(True) 208 | self.core.tracklist.set_single(True) 209 | case "Playlist": 210 | self.core.tracklist.set_repeat(True) 211 | self.core.tracklist.set_single(False) 212 | 213 | @property 214 | def Rate(self) -> float: 215 | self.log_trace("Getting %s.Rate", self.INTERFACE) 216 | return 1.0 217 | 218 | @Rate.setter 219 | def Rate(self, value: float) -> None: 220 | if not self.CanControl: 221 | # NOTE The spec does not explicitly require this check, but it was 222 | # added to be consistent with all the other property setters. 223 | logger.debug("Setting %s.Rate not allowed", self.INTERFACE) 224 | return 225 | logger.debug("Setting %s.Rate to %s", self.INTERFACE, value) 226 | if value == 0: 227 | self.Pause() 228 | 229 | @property 230 | def Shuffle(self) -> bool: 231 | self.log_trace("Getting %s.Shuffle", self.INTERFACE) 232 | return self.core.tracklist.get_random().get() 233 | 234 | @Shuffle.setter 235 | def Shuffle(self, value: bool) -> None: 236 | if not self.CanControl: 237 | logger.debug("Setting %s.Shuffle not allowed", self.INTERFACE) 238 | return 239 | logger.debug("Setting %s.Shuffle to %s", self.INTERFACE, value) 240 | self.core.tracklist.set_random(bool(value)) 241 | 242 | @property 243 | def Metadata(self) -> dict[str, Variant]: # noqa: C901 244 | self.log_trace("Getting %s.Metadata", self.INTERFACE) 245 | current_tl_track = self.core.playback.get_current_tl_track().get() 246 | stream_title = self.core.playback.get_stream_title().get() 247 | if current_tl_track is None: 248 | return {} 249 | track_id = get_track_id(current_tl_track.tlid) 250 | res = {"mpris:trackid": Variant("o", track_id)} 251 | track = current_tl_track.track 252 | if track.length: 253 | res["mpris:length"] = Variant("x", track.length * 1000) 254 | if track.uri: 255 | res["xesam:url"] = Variant("s", track.uri) 256 | if stream_title or track.name: 257 | res["xesam:title"] = Variant("s", stream_title or track.name) 258 | if track.artists: 259 | artists = list(track.artists) 260 | artists.sort(key=lambda a: a.name or "") 261 | res["xesam:artist"] = Variant("as", [a.name for a in artists if a.name]) 262 | if track.album and track.album.name: 263 | res["xesam:album"] = Variant("s", track.album.name) 264 | if track.album and track.album.artists: 265 | artists = list(track.album.artists) 266 | artists.sort(key=lambda a: a.name or "") 267 | res["xesam:albumArtist"] = Variant( 268 | "as", [a.name for a in artists if a.name] 269 | ) 270 | art_url = self._get_art_url(track) 271 | if art_url: 272 | res["mpris:artUrl"] = Variant("s", art_url) 273 | if track.disc_no: 274 | res["xesam:discNumber"] = Variant("i", track.disc_no) 275 | if track.track_no: 276 | res["xesam:trackNumber"] = Variant("i", track.track_no) 277 | return res 278 | 279 | def _get_art_url(self, track: Track) -> Uri | None: 280 | if track.uri is None: 281 | return None 282 | images = self.core.library.get_images([track.uri]).get() 283 | if images[track.uri]: 284 | largest_image = sorted( 285 | images[track.uri], key=lambda i: i.width or 0, reverse=True 286 | )[0] 287 | return largest_image.uri 288 | return None 289 | 290 | @property 291 | def Volume(self) -> float: 292 | self.log_trace("Getting %s.Volume", self.INTERFACE) 293 | mute = self.core.mixer.get_mute().get() 294 | volume = self.core.mixer.get_volume().get() 295 | if volume is None or mute is True: 296 | return 0 297 | return volume / 100.0 298 | 299 | @Volume.setter 300 | def Volume(self, value: float | None) -> None: 301 | if not self.CanControl: 302 | logger.debug("Setting %s.Volume not allowed", self.INTERFACE) 303 | return 304 | logger.debug("Setting %s.Volume to %s", self.INTERFACE, value) 305 | if value is None: 306 | return 307 | percentage = Percentage(max(0, min(100, round(value * 100)))) 308 | self.core.mixer.set_volume(percentage) 309 | if percentage > 0: 310 | self.core.mixer.set_mute(False) 311 | 312 | @property 313 | def Position(self) -> int: 314 | self.log_trace("Getting %s.Position", self.INTERFACE) 315 | return self.core.playback.get_time_position().get() * 1000 316 | 317 | MinimumRate: float = 1.0 318 | MaximumRate: float = 1.0 319 | 320 | @property 321 | def CanGoNext(self) -> bool: 322 | self.log_trace("Getting %s.CanGoNext", self.INTERFACE) 323 | if not self.CanControl: 324 | return False 325 | current_tlid = self.core.playback.get_current_tlid().get() 326 | next_tlid = self.core.tracklist.get_next_tlid().get() 327 | return next_tlid != current_tlid 328 | 329 | @property 330 | def CanGoPrevious(self) -> bool: 331 | self.log_trace("Getting %s.CanGoPrevious", self.INTERFACE) 332 | if not self.CanControl: 333 | return False 334 | current_tlid = self.core.playback.get_current_tlid().get() 335 | previous_tlid = self.core.tracklist.get_previous_tlid().get() 336 | return previous_tlid != current_tlid 337 | 338 | @property 339 | def CanPlay(self) -> bool: 340 | self.log_trace("Getting %s.CanPlay", self.INTERFACE) 341 | if not self.CanControl: 342 | return False 343 | current_tlid = self.core.playback.get_current_tlid().get() 344 | next_tlid = self.core.tracklist.get_next_tlid().get() 345 | return current_tlid is not None or next_tlid is not None 346 | 347 | @property 348 | def CanPause(self) -> bool: 349 | self.log_trace("Getting %s.CanPause", self.INTERFACE) 350 | if not self.CanControl: # noqa: SIM103 351 | return False 352 | # NOTE Should be changed to vary based on capabilities of the current 353 | # track if Mopidy starts supporting non-seekable media, like streams. 354 | return True 355 | 356 | @property 357 | def CanSeek(self) -> bool: 358 | self.log_trace("Getting %s.CanSeek", self.INTERFACE) 359 | if not self.CanControl: # noqa: SIM103 360 | return False 361 | # NOTE Should be changed to vary based on capabilities of the current 362 | # track if Mopidy starts supporting non-seekable media, like streams. 363 | return True 364 | 365 | @property 366 | def CanControl(self) -> bool: 367 | # NOTE This could be a setting for the end user to change. 368 | return self._CanControl 369 | 370 | 371 | def get_track_id(tlid: TracklistId) -> str: 372 | return f"/com/mopidy/track/{tlid}" 373 | 374 | 375 | def get_track_tlid(track_id: str) -> TracklistId: 376 | if not track_id.startswith("/com/mopidy/track/"): 377 | msg = f"Cannot extract track ID from {track_id!r}" 378 | raise ValueError(msg) 379 | return TracklistId(int(track_id.split("/")[-1])) 380 | -------------------------------------------------------------------------------- /tests/test_player.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | from gi.repository import GLib # pyright: ignore[reportMissingModuleSource] 7 | from mopidy.models import Album, Artist, Image, Track 8 | from mopidy.types import PlaybackState 9 | 10 | from mopidy_mpris.player import Player 11 | 12 | if TYPE_CHECKING: 13 | from mopidy.core import CoreProxy 14 | 15 | PLAYING = PlaybackState.PLAYING 16 | PAUSED = PlaybackState.PAUSED 17 | STOPPED = PlaybackState.STOPPED 18 | 19 | 20 | @pytest.fixture 21 | def player(config, core) -> Player: 22 | return Player(config, core) 23 | 24 | 25 | @pytest.mark.parametrize( 26 | ("state", "expected"), 27 | [(PLAYING, "Playing"), (PAUSED, "Paused"), (STOPPED, "Stopped")], 28 | ) 29 | def test_get_playback_status(core: CoreProxy, player: Player, state, expected): 30 | core.playback.set_state(state) 31 | 32 | assert player.PlaybackStatus == expected 33 | 34 | 35 | @pytest.mark.parametrize( 36 | ("repeat", "single", "expected"), 37 | [ 38 | (False, False, "None"), 39 | (False, True, "None"), 40 | (True, False, "Playlist"), 41 | (True, True, "Track"), 42 | ], 43 | ) 44 | def test_get_loop_status(core: CoreProxy, player: Player, repeat, single, expected): 45 | core.tracklist.set_repeat(repeat) 46 | core.tracklist.set_single(single) 47 | 48 | assert player.LoopStatus == expected 49 | 50 | 51 | @pytest.mark.parametrize( 52 | ("status", "expected_repeat", "expected_single"), 53 | [("None", False, False), ("Track", True, True), ("Playlist", True, False)], 54 | ) 55 | def test_set_loop_status( 56 | core: CoreProxy, player: Player, status, expected_repeat, expected_single 57 | ): 58 | player.LoopStatus = status 59 | 60 | assert core.tracklist.get_repeat().get() is expected_repeat 61 | assert core.tracklist.get_single().get() is expected_single 62 | 63 | 64 | def test_set_loop_status_is_ignored_if_can_control_is_false( 65 | core: CoreProxy, player: Player 66 | ): 67 | player._CanControl = False 68 | core.tracklist.set_repeat(True) 69 | core.tracklist.set_single(True) 70 | 71 | player.LoopStatus = "None" 72 | 73 | assert core.tracklist.get_repeat().get() is True 74 | assert core.tracklist.get_single().get() is True 75 | 76 | 77 | def test_get_rate_is_greater_or_equal_than_minimum_rate(player: Player): 78 | assert player.Rate >= player.MinimumRate 79 | 80 | 81 | def test_get_rate_is_less_or_equal_than_maximum_rate(player: Player): 82 | assert player.Rate <= player.MaximumRate 83 | 84 | 85 | def test_set_rate_to_zero_pauses_playback(core: CoreProxy, player: Player): 86 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 87 | core.playback.play().get() 88 | assert core.playback.get_state().get() == PLAYING 89 | 90 | player.Rate = 0 91 | 92 | assert core.playback.get_state().get() == PAUSED 93 | 94 | 95 | def test_set_rate_is_ignored_if_can_control_is_false(core: CoreProxy, player: Player): 96 | player._CanControl = False 97 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 98 | core.playback.play().get() 99 | assert core.playback.get_state().get() == PLAYING 100 | 101 | player.Rate = 0 102 | 103 | assert core.playback.get_state().get() == PLAYING 104 | 105 | 106 | @pytest.mark.parametrize("random", [True, False]) 107 | def test_get_shuffle(core: CoreProxy, player: Player, random): 108 | core.tracklist.set_random(random) 109 | 110 | assert player.Shuffle is random 111 | 112 | 113 | @pytest.mark.parametrize("value", [True, False]) 114 | def test_set_shuffle(core: CoreProxy, player: Player, value): 115 | core.tracklist.set_random(not value) 116 | assert core.tracklist.get_random().get() is not value 117 | 118 | player.Shuffle = value 119 | 120 | assert core.tracklist.get_random().get() is value 121 | 122 | 123 | def test_set_shuffle_is_ignored_if_can_control_is_false( 124 | core: CoreProxy, player: Player 125 | ): 126 | player._CanControl = False 127 | core.tracklist.set_random(False) 128 | 129 | player.Shuffle = True 130 | 131 | assert core.tracklist.get_random().get() is False 132 | 133 | 134 | def test_get_metadata_is_empty_when_no_current_track(player: Player): 135 | assert player.Metadata == {} 136 | 137 | 138 | def test_get_metadata(core: CoreProxy, player: Player): 139 | core.tracklist.add( 140 | [ 141 | Track( 142 | uri="dummy:a", 143 | length=3600000, 144 | name="a", 145 | artists=[Artist(name="b"), Artist(name="c"), Artist(name=None)], 146 | album=Album(name="d", artists=[Artist(name="e"), Artist(name=None)]), 147 | ) 148 | ] 149 | ) 150 | core.playback.play().get() 151 | 152 | (tlid, _track) = core.playback.get_current_tl_track().get() 153 | 154 | result = player.Metadata 155 | 156 | assert result["mpris:trackid"] == GLib.Variant("o", f"/com/mopidy/track/{tlid}") 157 | assert result["mpris:length"] == GLib.Variant("x", 3600000000) 158 | assert result["xesam:url"] == GLib.Variant("s", "dummy:a") 159 | assert result["xesam:title"] == GLib.Variant("s", "a") 160 | assert result["xesam:artist"] == GLib.Variant("as", ["b", "c"]) 161 | assert result["xesam:album"] == GLib.Variant("s", "d") 162 | assert result["xesam:albumArtist"] == GLib.Variant("as", ["e"]) 163 | 164 | 165 | def test_get_metadata_prefers_stream_title_over_track_name( 166 | audio, core: CoreProxy, player 167 | ): 168 | core.tracklist.add([Track(uri="dummy:a", name="Track name")]) 169 | core.playback.play().get() 170 | 171 | result = player.Metadata 172 | assert result["xesam:title"] == GLib.Variant("s", "Track name") 173 | 174 | audio.trigger_fake_tags_changed( 175 | { 176 | "organization": ["Required for Mopidy core to care about the title"], 177 | "title": ["Stream title"], 178 | } 179 | ).get() 180 | 181 | result = player.Metadata 182 | assert result["xesam:title"] == GLib.Variant("s", "Stream title") 183 | 184 | 185 | def test_get_metadata_use_library_image_as_art_url( 186 | backend, core: CoreProxy, player: Player 187 | ): 188 | backend.library.dummy_get_images_result = { 189 | "dummy:a": [ 190 | Image(uri="http://example.com/small.jpg", width=100, height=100), 191 | Image(uri="http://example.com/large.jpg", width=200, height=200), 192 | Image(uri="http://example.com/unsized.jpg"), 193 | ], 194 | } 195 | core.tracklist.add([Track(uri="dummy:a")]) 196 | core.playback.play().get() 197 | 198 | result = player.Metadata 199 | 200 | assert result["mpris:artUrl"] == GLib.Variant("s", "http://example.com/large.jpg") 201 | 202 | 203 | def test_get_metadata_has_disc_number_in_album(core: CoreProxy, player: Player): 204 | core.tracklist.add([Track(uri="dummy:a", disc_no=2)]) 205 | core.playback.play().get() 206 | 207 | assert player.Metadata["xesam:discNumber"] == GLib.Variant("i", 2) 208 | 209 | 210 | def test_get_metadata_has_track_number_in_album(core: CoreProxy, player: Player): 211 | core.tracklist.add([Track(uri="dummy:a", track_no=7)]) 212 | core.playback.play().get() 213 | 214 | assert player.Metadata["xesam:trackNumber"] == GLib.Variant("i", 7) 215 | 216 | 217 | def test_get_volume_should_return_volume_between_zero_and_one( 218 | core: CoreProxy, player: Player 219 | ): 220 | # dummy_mixer starts out with None as the volume 221 | assert player.Volume == 0 222 | 223 | core.mixer.set_volume(0) 224 | assert player.Volume == 0 225 | 226 | core.mixer.set_volume(50) 227 | assert player.Volume == 0.5 228 | 229 | core.mixer.set_volume(100) 230 | assert player.Volume == 1 231 | 232 | 233 | def test_get_volume_should_return_0_if_muted(core: CoreProxy, player: Player): 234 | assert player.Volume == 0 235 | 236 | core.mixer.set_volume(100) 237 | assert player.Volume == 1 238 | 239 | core.mixer.set_mute(True) 240 | assert player.Volume == 0 241 | 242 | core.mixer.set_mute(False) 243 | assert player.Volume == 1 244 | 245 | 246 | @pytest.mark.parametrize( 247 | ("volume", "expected"), 248 | [(-1.0, 0), (0, 0), (0.5, 50), (1.0, 100), (2.0, 100)], 249 | ) 250 | def test_set_volume(core: CoreProxy, player: Player, volume, expected): 251 | player.Volume = volume 252 | 253 | assert core.mixer.get_volume().get() == expected 254 | 255 | 256 | def test_set_volume_to_not_a_number_does_not_change_volume( 257 | core: CoreProxy, player: Player 258 | ): 259 | core.mixer.set_volume(10).get() 260 | 261 | player.Volume = None 262 | 263 | assert core.mixer.get_volume().get() == 10 264 | 265 | 266 | def test_set_volume_is_ignored_if_can_control_is_false(core: CoreProxy, player: Player): 267 | player._CanControl = False 268 | core.mixer.set_volume(0) 269 | 270 | player.Volume = 1.0 271 | 272 | assert core.mixer.get_volume().get() == 0 273 | 274 | 275 | def test_set_volume_to_positive_value_unmutes_if_muted(core: CoreProxy, player: Player): 276 | core.mixer.set_volume(10).get() 277 | core.mixer.set_mute(True).get() 278 | 279 | player.Volume = 1.0 280 | 281 | assert core.mixer.get_volume().get() == 100 282 | assert core.mixer.get_mute().get() is False 283 | 284 | 285 | def test_set_volume_to_zero_does_not_unmute_if_muted(core: CoreProxy, player: Player): 286 | core.mixer.set_volume(10).get() 287 | core.mixer.set_mute(True).get() 288 | 289 | player.Volume = 0.0 290 | 291 | assert core.mixer.get_volume().get() == 0 292 | assert core.mixer.get_mute().get() is True 293 | 294 | 295 | def test_get_position_returns_time_position_in_microseconds( 296 | core: CoreProxy, player: Player 297 | ): 298 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 299 | core.playback.play().get() 300 | core.playback.seek(10000).get() 301 | 302 | result_in_microseconds = player.Position 303 | 304 | result_in_milliseconds = result_in_microseconds // 1000 305 | assert result_in_milliseconds >= 10000 306 | 307 | 308 | def test_get_position_when_no_current_track_should_be_zero(player: Player): 309 | result_in_microseconds = player.Position 310 | 311 | result_in_milliseconds = result_in_microseconds // 1000 312 | assert result_in_milliseconds == 0 313 | 314 | 315 | def test_get_minimum_rate_is_one_or_less(player: Player): 316 | assert player.MinimumRate <= 1.0 317 | 318 | 319 | def test_get_maximum_rate_is_one_or_more(player: Player): 320 | assert player.MaximumRate >= 1.0 321 | 322 | 323 | def test_can_go_next_is_true_if_can_control_and_other_next_track( 324 | core: CoreProxy, player 325 | ): 326 | player._CanControl = True 327 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 328 | core.playback.play().get() 329 | 330 | assert player.CanGoNext 331 | 332 | 333 | def test_can_go_next_is_false_if_next_track_is_the_same( 334 | core: CoreProxy, player: Player 335 | ): 336 | player._CanControl = True 337 | core.tracklist.add([Track(uri="dummy:a")]) 338 | core.tracklist.set_repeat(True) 339 | core.playback.play().get() 340 | 341 | assert not player.CanGoNext 342 | 343 | 344 | def test_can_go_next_is_false_if_can_control_is_false(core: CoreProxy, player: Player): 345 | player._CanControl = False 346 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 347 | core.playback.play().get() 348 | 349 | assert not player.CanGoNext 350 | 351 | 352 | def test_can_go_previous_is_true_if_can_control_and_previous_track( 353 | core: CoreProxy, player 354 | ): 355 | player._CanControl = True 356 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 357 | core.playback.play().get() 358 | core.playback.next().get() 359 | 360 | assert player.CanGoPrevious 361 | 362 | 363 | def test_can_go_previous_is_false_if_previous_track_is_the_same( 364 | core: CoreProxy, player 365 | ): 366 | player._CanControl = True 367 | core.tracklist.add([Track(uri="dummy:a")]) 368 | core.tracklist.set_repeat(True) 369 | core.playback.play().get() 370 | 371 | assert not player.CanGoPrevious 372 | 373 | 374 | def test_can_go_previous_is_false_if_can_control_is_false( 375 | core: CoreProxy, player: Player 376 | ): 377 | player._CanControl = False 378 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 379 | core.playback.play().get() 380 | core.playback.next().get() 381 | 382 | assert not player.CanGoPrevious 383 | 384 | 385 | def test_can_play_is_true_if_can_control_and_current_track( 386 | core: CoreProxy, player: Player 387 | ): 388 | player._CanControl = True 389 | core.tracklist.add([Track(uri="dummy:a")]) 390 | core.playback.play().get() 391 | assert core.playback.get_current_track().get() 392 | 393 | assert player.CanPlay 394 | 395 | 396 | def test_can_play_is_false_if_no_current_track(core: CoreProxy, player: Player): 397 | player._CanControl = True 398 | assert not core.playback.get_current_track().get() 399 | 400 | assert not player.CanPlay 401 | 402 | 403 | def test_can_play_if_false_if_can_control_is_false(core: CoreProxy, player: Player): 404 | player._CanControl = False 405 | 406 | assert not player.CanPlay 407 | 408 | 409 | def test_can_pause_is_true_if_can_control_and_track_can_be_paused( 410 | core: CoreProxy, player 411 | ): 412 | player._CanControl = True 413 | 414 | assert player.CanPause 415 | 416 | 417 | def test_can_pause_if_false_if_can_control_is_false(core: CoreProxy, player: Player): 418 | player._CanControl = False 419 | 420 | assert not player.CanPause 421 | 422 | 423 | def test_can_seek_is_true_if_can_control_is_true(core: CoreProxy, player: Player): 424 | player._CanControl = True 425 | 426 | assert player.CanSeek 427 | 428 | 429 | def test_can_seek_is_false_if_can_control_is_false(core: CoreProxy, player: Player): 430 | player._CanControl = False 431 | result = player.CanSeek 432 | assert not result 433 | 434 | 435 | def test_can_control_is_true(core: CoreProxy, player: Player): 436 | result = player.CanControl 437 | assert result 438 | 439 | 440 | def test_next_is_ignored_if_can_go_next_is_false(core: CoreProxy, player: Player): 441 | player._CanControl = False 442 | assert not player.CanGoNext 443 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 444 | core.playback.play().get() 445 | assert core.playback.get_current_track().get().uri == "dummy:a" 446 | 447 | player.Next() 448 | 449 | assert core.playback.get_current_track().get().uri == "dummy:a" 450 | 451 | 452 | def test_next_when_playing_skips_to_next_track_and_keep_playing( 453 | core: CoreProxy, player 454 | ): 455 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 456 | core.playback.play().get() 457 | assert core.playback.get_current_track().get().uri == "dummy:a" 458 | assert core.playback.get_state().get() == PLAYING 459 | 460 | player.Next() 461 | 462 | assert core.playback.get_current_track().get().uri == "dummy:b" 463 | assert core.playback.get_state().get() == PLAYING 464 | 465 | 466 | def test_next_when_at_end_of_list_should_stop_playback(core: CoreProxy, player: Player): 467 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 468 | core.playback.play().get() 469 | core.playback.next().get() 470 | assert core.playback.get_current_track().get().uri == "dummy:b" 471 | assert core.playback.get_state().get() == PLAYING 472 | player.Next() 473 | assert core.playback.get_state().get() == STOPPED 474 | 475 | 476 | def test_next_when_paused_should_skip_to_next_track_and_stay_paused( 477 | core: CoreProxy, player 478 | ): 479 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 480 | core.playback.play().get() 481 | core.playback.pause().get() 482 | assert core.playback.get_current_track().get().uri == "dummy:a" 483 | assert core.playback.get_state().get() == PAUSED 484 | player.Next() 485 | assert core.playback.get_current_track().get().uri == "dummy:b" 486 | assert core.playback.get_state().get() == PAUSED 487 | 488 | 489 | def test_next_when_stopped_skips_to_next_track_and_stay_stopped( 490 | core: CoreProxy, player 491 | ): 492 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 493 | core.playback.play().get() 494 | core.playback.stop() 495 | assert core.playback.get_current_track().get().uri == "dummy:a" 496 | assert core.playback.get_state().get() == STOPPED 497 | player.Next() 498 | assert core.playback.get_current_track().get().uri == "dummy:b" 499 | assert core.playback.get_state().get() == STOPPED 500 | 501 | 502 | def test_previous_is_ignored_if_can_go_previous_is_false( 503 | core: CoreProxy, player: Player 504 | ): 505 | player._CanControl = False 506 | assert not player.CanGoPrevious 507 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 508 | core.playback.play().get() 509 | core.playback.next().get() 510 | assert core.playback.get_current_track().get().uri == "dummy:b" 511 | 512 | player.Previous() 513 | 514 | assert core.playback.get_current_track().get().uri == "dummy:b" 515 | 516 | 517 | def test_previous_when_playing_skips_to_prev_track_and_keep_playing( 518 | core: CoreProxy, player: Player 519 | ): 520 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 521 | core.playback.play().get() 522 | core.playback.next().get() 523 | assert core.playback.get_current_track().get().uri == "dummy:b" 524 | assert core.playback.get_state().get() == PLAYING 525 | 526 | player.Previous() 527 | 528 | assert core.playback.get_current_track().get().uri == "dummy:a" 529 | assert core.playback.get_state().get() == PLAYING 530 | 531 | 532 | def test_previous_when_at_start_of_list_should_stop_playback( 533 | core: CoreProxy, player: Player 534 | ): 535 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 536 | core.playback.play().get() 537 | assert core.playback.get_current_track().get().uri == "dummy:a" 538 | assert core.playback.get_state().get() == PLAYING 539 | 540 | player.Previous() 541 | 542 | assert core.playback.get_state().get() == STOPPED 543 | 544 | 545 | def test_previous_when_paused_skips_to_previous_track_and_pause( 546 | core: CoreProxy, player: Player 547 | ): 548 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 549 | core.playback.play().get() 550 | core.playback.next().get() 551 | core.playback.pause().get() 552 | assert core.playback.get_current_track().get().uri == "dummy:b" 553 | assert core.playback.get_state().get() == PAUSED 554 | 555 | player.Previous() 556 | 557 | assert core.playback.get_current_track().get().uri == "dummy:a" 558 | assert core.playback.get_state().get() == PAUSED 559 | 560 | 561 | def test_previous_when_stopped_skips_to_previous_track_and_stops( 562 | core: CoreProxy, player: Player 563 | ): 564 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 565 | core.playback.play().get() 566 | core.playback.next().get() 567 | core.playback.stop() 568 | assert core.playback.get_current_track().get().uri == "dummy:b" 569 | assert core.playback.get_state().get() == STOPPED 570 | 571 | player.Previous() 572 | 573 | assert core.playback.get_current_track().get().uri == "dummy:a" 574 | assert core.playback.get_state().get() == STOPPED 575 | 576 | 577 | def test_pause_is_ignored_if_can_pause_is_false(core: CoreProxy, player: Player): 578 | player._CanControl = False 579 | assert not player.CanPause 580 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 581 | core.playback.play().get() 582 | assert core.playback.get_state().get() == PLAYING 583 | 584 | player.Pause() 585 | 586 | assert core.playback.get_state().get() == PLAYING 587 | 588 | 589 | def test_pause_when_playing_should_pause_playback(core: CoreProxy, player: Player): 590 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 591 | core.playback.play().get() 592 | assert core.playback.get_state().get() == PLAYING 593 | 594 | player.Pause() 595 | 596 | assert core.playback.get_state().get() == PAUSED 597 | 598 | 599 | def test_pause_when_paused_has_no_effect(core: CoreProxy, player: Player): 600 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 601 | core.playback.play().get() 602 | core.playback.pause().get() 603 | assert core.playback.get_state().get() == PAUSED 604 | 605 | player.Pause() 606 | 607 | assert core.playback.get_state().get() == PAUSED 608 | 609 | 610 | def test_playpause_is_ignored_if_can_pause_is_false(core: CoreProxy, player: Player): 611 | player._CanControl = False 612 | assert not player.CanPause 613 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 614 | core.playback.play().get() 615 | assert core.playback.get_state().get() == PLAYING 616 | 617 | player.PlayPause() 618 | 619 | assert core.playback.get_state().get() == PLAYING 620 | 621 | 622 | def test_playpause_when_playing_should_pause_playback(core: CoreProxy, player: Player): 623 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 624 | core.playback.play().get() 625 | assert core.playback.get_state().get() == PLAYING 626 | 627 | player.PlayPause() 628 | 629 | assert core.playback.get_state().get() == PAUSED 630 | 631 | 632 | def test_playpause_when_paused_should_resume_playback(core: CoreProxy, player: Player): 633 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 634 | core.playback.play().get() 635 | core.playback.pause().get() 636 | 637 | assert core.playback.get_state().get() == PAUSED 638 | at_pause = core.playback.get_time_position().get() 639 | assert at_pause >= 0 640 | 641 | player.PlayPause() 642 | 643 | assert core.playback.get_state().get() == PLAYING 644 | after_pause = core.playback.get_time_position().get() 645 | assert after_pause >= at_pause 646 | 647 | 648 | def test_playpause_when_stopped_should_start_playback(core: CoreProxy, player: Player): 649 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 650 | assert core.playback.get_state().get() == STOPPED 651 | 652 | player.PlayPause() 653 | 654 | assert core.playback.get_state().get() == PLAYING 655 | 656 | 657 | def test_stop_is_ignored_if_can_control_is_false(core: CoreProxy, player: Player): 658 | player._CanControl = False 659 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 660 | core.playback.play().get() 661 | assert core.playback.get_state().get() == PLAYING 662 | 663 | player.Stop() 664 | 665 | assert core.playback.get_state().get() == PLAYING 666 | 667 | 668 | def test_stop_when_playing_should_stop_playback(core: CoreProxy, player: Player): 669 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 670 | core.playback.play().get() 671 | assert core.playback.get_state().get() == PLAYING 672 | 673 | player.Stop() 674 | 675 | assert core.playback.get_state().get() == STOPPED 676 | 677 | 678 | def test_stop_when_paused_should_stop_playback(core: CoreProxy, player: Player): 679 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 680 | core.playback.play().get() 681 | core.playback.pause().get() 682 | assert core.playback.get_state().get() == PAUSED 683 | 684 | player.Stop() 685 | 686 | assert core.playback.get_state().get() == STOPPED 687 | 688 | 689 | def test_play_is_ignored_if_can_play_is_false(core: CoreProxy, player: Player): 690 | player._CanControl = False 691 | assert not player.CanPlay 692 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 693 | assert core.playback.get_state().get() == STOPPED 694 | 695 | player.Play() 696 | 697 | assert core.playback.get_state().get() == STOPPED 698 | 699 | 700 | def test_play_when_stopped_starts_playback(core: CoreProxy, player: Player): 701 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 702 | assert core.playback.get_state().get() == STOPPED 703 | 704 | player.Play() 705 | 706 | assert core.playback.get_state().get() == PLAYING 707 | 708 | 709 | def test_play_after_pause_resumes_from_same_position(core: CoreProxy, player: Player): 710 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 711 | core.playback.play().get() 712 | 713 | before_pause = core.playback.get_time_position().get() 714 | assert before_pause >= 0 715 | 716 | player.Pause() 717 | 718 | assert core.playback.get_state().get() == PAUSED 719 | at_pause = core.playback.get_time_position().get() 720 | assert at_pause >= before_pause 721 | 722 | player.Play() 723 | 724 | assert core.playback.get_state().get() == PLAYING 725 | after_pause = core.playback.get_time_position().get() 726 | assert after_pause >= at_pause 727 | 728 | 729 | def test_play_when_there_is_no_track_has_no_effect(core: CoreProxy, player: Player): 730 | core.tracklist.clear() 731 | assert core.playback.get_state().get() == STOPPED 732 | 733 | player.Play() 734 | 735 | assert core.playback.get_state().get() == STOPPED 736 | 737 | 738 | def test_seek_is_ignored_if_can_seek_is_false(core: CoreProxy, player: Player): 739 | player._CanControl = False 740 | assert not player.CanSeek 741 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 742 | core.playback.play().get() 743 | 744 | before_seek = core.playback.get_time_position().get() 745 | assert before_seek >= 0 746 | 747 | milliseconds_to_seek = 10000 748 | microseconds_to_seek = milliseconds_to_seek * 1000 749 | 750 | player.Seek(microseconds_to_seek) 751 | 752 | after_seek = core.playback.get_time_position().get() 753 | assert before_seek <= after_seek 754 | assert after_seek < before_seek + milliseconds_to_seek 755 | 756 | 757 | def test_seek_seeks_given_microseconds_forward_in_the_current_track( 758 | core: CoreProxy, player: Player 759 | ): 760 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 761 | core.playback.play().get() 762 | 763 | before_seek = core.playback.get_time_position().get() 764 | assert before_seek >= 0 765 | 766 | milliseconds_to_seek = 10000 767 | microseconds_to_seek = milliseconds_to_seek * 1000 768 | 769 | player.Seek(microseconds_to_seek) 770 | 771 | assert core.playback.get_state().get() == PLAYING 772 | 773 | after_seek = core.playback.get_time_position().get() 774 | assert after_seek >= before_seek + milliseconds_to_seek 775 | 776 | 777 | def test_seek_seeks_given_microseconds_backward_if_negative( 778 | core: CoreProxy, player: Player 779 | ): 780 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 781 | core.playback.play().get() 782 | core.playback.seek(20000).get() 783 | 784 | before_seek = core.playback.get_time_position().get() 785 | assert before_seek >= 20000 786 | 787 | milliseconds_to_seek = -10000 788 | microseconds_to_seek = milliseconds_to_seek * 1000 789 | 790 | player.Seek(microseconds_to_seek) 791 | 792 | assert core.playback.get_state().get() == PLAYING 793 | 794 | after_seek = core.playback.get_time_position().get() 795 | assert after_seek >= before_seek + milliseconds_to_seek 796 | assert after_seek < before_seek 797 | 798 | 799 | def test_seek_seeks_to_start_of_track_if_new_position_is_negative( 800 | core: CoreProxy, player: Player 801 | ): 802 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 803 | core.playback.play().get() 804 | core.playback.seek(20000).get() 805 | 806 | before_seek = core.playback.get_time_position().get() 807 | assert before_seek >= 20000 808 | 809 | milliseconds_to_seek = -30000 810 | microseconds_to_seek = milliseconds_to_seek * 1000 811 | 812 | player.Seek(microseconds_to_seek) 813 | 814 | assert core.playback.get_state().get() == PLAYING 815 | 816 | after_seek = core.playback.get_time_position().get() 817 | assert after_seek >= before_seek + milliseconds_to_seek 818 | assert after_seek < before_seek 819 | assert after_seek >= 0 820 | 821 | 822 | def test_seek_skips_to_next_track_if_new_position_gt_track_length( 823 | core: CoreProxy, player: Player 824 | ): 825 | core.tracklist.add([Track(uri="dummy:a", length=40000), Track(uri="dummy:b")]) 826 | core.playback.play().get() 827 | core.playback.seek(20000).get() 828 | 829 | before_seek = core.playback.get_time_position().get() 830 | assert before_seek >= 20000 831 | assert core.playback.get_state().get() == PLAYING 832 | assert core.playback.get_current_track().get().uri == "dummy:a" 833 | 834 | milliseconds_to_seek = 50000 835 | microseconds_to_seek = milliseconds_to_seek * 1000 836 | 837 | player.Seek(microseconds_to_seek) 838 | 839 | assert core.playback.get_state().get() == PLAYING 840 | assert core.playback.get_current_track().get().uri == "dummy:b" 841 | 842 | after_seek = core.playback.get_time_position().get() 843 | assert after_seek >= 0 844 | assert after_seek < before_seek 845 | 846 | 847 | def test_set_position_is_ignored_if_can_seek_is_false(core: CoreProxy, player: Player): 848 | player.get_CanSeek = lambda *_: False 849 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 850 | core.playback.play().get() 851 | 852 | before_set_position = core.playback.get_time_position().get() 853 | assert before_set_position <= 5000 854 | 855 | track_id = "a" 856 | 857 | position_to_set_in_millisec = 20000 858 | position_to_set_in_microsec = position_to_set_in_millisec * 1000 859 | 860 | player.SetPosition(track_id, position_to_set_in_microsec) 861 | 862 | after_set_position = core.playback.get_time_position().get() 863 | assert before_set_position <= after_set_position 864 | assert after_set_position < position_to_set_in_millisec 865 | 866 | 867 | def test_set_position_sets_the_current_track_position_in_microsecs( 868 | core: CoreProxy, player: Player 869 | ): 870 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 871 | core.playback.play().get() 872 | 873 | before_set_position = core.playback.get_time_position().get() 874 | assert before_set_position <= 5000 875 | assert core.playback.get_state().get() == PLAYING 876 | 877 | track_id = "/com/mopidy/track/1" 878 | 879 | position_to_set_in_millisec = 20000 880 | position_to_set_in_microsec = position_to_set_in_millisec * 1000 881 | 882 | player.SetPosition(track_id, position_to_set_in_microsec) 883 | 884 | assert core.playback.get_state().get() == PLAYING 885 | 886 | after_set_position = core.playback.get_time_position().get() 887 | assert after_set_position >= position_to_set_in_millisec 888 | 889 | 890 | def test_set_position_does_nothing_if_the_position_is_negative( 891 | core: CoreProxy, player: Player 892 | ): 893 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 894 | core.playback.play().get() 895 | core.playback.seek(20000) 896 | 897 | before_set_position = core.playback.get_time_position().get() 898 | assert before_set_position >= 20000 899 | assert before_set_position <= 25000 900 | assert core.playback.get_state().get() == PLAYING 901 | assert core.playback.get_current_track().get().uri == "dummy:a" 902 | 903 | track_id = "/com/mopidy/track/1" 904 | 905 | position_to_set_in_millisec = -1000 906 | position_to_set_in_microsec = position_to_set_in_millisec * 1000 907 | 908 | player.SetPosition(track_id, position_to_set_in_microsec) 909 | 910 | after_set_position = core.playback.get_time_position().get() 911 | assert after_set_position >= before_set_position 912 | assert core.playback.get_state().get() == PLAYING 913 | assert core.playback.get_current_track().get().uri == "dummy:a" 914 | 915 | 916 | def test_set_position_does_nothing_if_position_is_gt_track_length( 917 | core: CoreProxy, player: Player 918 | ): 919 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 920 | core.playback.play().get() 921 | core.playback.seek(20000) 922 | 923 | before_set_position = core.playback.get_time_position().get() 924 | assert before_set_position >= 20000 925 | assert before_set_position <= 25000 926 | assert core.playback.get_state().get() == PLAYING 927 | assert core.playback.get_current_track().get().uri == "dummy:a" 928 | 929 | track_id = "a" 930 | 931 | position_to_set_in_millisec = 50000 932 | position_to_set_in_microsec = position_to_set_in_millisec * 1000 933 | 934 | player.SetPosition(track_id, position_to_set_in_microsec) 935 | 936 | after_set_position = core.playback.get_time_position().get() 937 | assert after_set_position >= before_set_position 938 | assert core.playback.get_state().get() == PLAYING 939 | assert core.playback.get_current_track().get().uri == "dummy:a" 940 | 941 | 942 | def test_set_position_is_noop_if_track_id_isnt_current_track( 943 | core: CoreProxy, player: Player 944 | ): 945 | core.tracklist.add([Track(uri="dummy:a", length=40000)]) 946 | core.playback.play().get() 947 | core.playback.seek(20000) 948 | 949 | before_set_position = core.playback.get_time_position().get() 950 | assert before_set_position >= 20000 951 | assert before_set_position <= 25000 952 | assert core.playback.get_state().get() == PLAYING 953 | assert core.playback.get_current_track().get().uri == "dummy:a" 954 | 955 | track_id = "b" 956 | 957 | position_to_set_in_millisec = 0 958 | position_to_set_in_microsec = position_to_set_in_millisec * 1000 959 | 960 | player.SetPosition(track_id, position_to_set_in_microsec) 961 | 962 | after_set_position = core.playback.get_time_position().get() 963 | assert after_set_position >= before_set_position 964 | assert core.playback.get_state().get() == PLAYING 965 | assert core.playback.get_current_track().get().uri == "dummy:a" 966 | 967 | 968 | def test_open_uri_is_ignored_if_can_control_is_false( 969 | backend, core: CoreProxy, player: Player 970 | ): 971 | player._CanControl = False 972 | backend.library.dummy_library = [Track(uri="dummy:/test/uri")] 973 | 974 | player.OpenUri("dummy:/test/uri") 975 | 976 | assert core.tracklist.get_length().get() == 0 977 | 978 | 979 | def test_open_uri_ignores_uris_with_unknown_uri_scheme( 980 | backend, core: CoreProxy, player: Player 981 | ): 982 | assert core.get_uri_schemes().get() == ["dummy"] 983 | backend.library.dummy_library = [Track(uri="notdummy:/test/uri")] 984 | 985 | player.OpenUri("notdummy:/test/uri") 986 | 987 | assert core.tracklist.get_length().get() == 0 988 | 989 | 990 | def test_open_uri_adds_uri_to_tracklist(backend, core: CoreProxy, player: Player): 991 | backend.library.dummy_library = [Track(uri="dummy:/test/uri")] 992 | 993 | player.OpenUri("dummy:/test/uri") 994 | 995 | assert core.tracklist.get_length().get() == 1 996 | assert core.tracklist.get_tracks().get()[0].uri == "dummy:/test/uri" 997 | 998 | 999 | def test_open_uri_starts_playback_of_new_track_if_stopped( 1000 | backend, core: CoreProxy, player: Player 1001 | ): 1002 | backend.library.dummy_library = [Track(uri="dummy:/test/uri")] 1003 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 1004 | assert core.playback.get_state().get() == STOPPED 1005 | 1006 | player.OpenUri("dummy:/test/uri") 1007 | 1008 | assert core.playback.get_state().get() == PLAYING 1009 | assert core.playback.get_current_track().get().uri == "dummy:/test/uri" 1010 | 1011 | 1012 | def test_open_uri_starts_playback_of_new_track_if_paused( 1013 | backend, core: CoreProxy, player: Player 1014 | ): 1015 | backend.library.dummy_library = [Track(uri="dummy:/test/uri")] 1016 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 1017 | core.playback.play().get() 1018 | core.playback.pause().get() 1019 | assert core.playback.get_state().get() == PAUSED 1020 | assert core.playback.get_current_track().get().uri == "dummy:a" 1021 | 1022 | player.OpenUri("dummy:/test/uri") 1023 | 1024 | assert core.playback.get_state().get() == PLAYING 1025 | assert core.playback.get_current_track().get().uri == "dummy:/test/uri" 1026 | 1027 | 1028 | def test_open_uri_starts_playback_of_new_track_if_playing( 1029 | backend, core: CoreProxy, player: Player 1030 | ): 1031 | backend.library.dummy_library = [Track(uri="dummy:/test/uri")] 1032 | core.tracklist.add([Track(uri="dummy:a"), Track(uri="dummy:b")]) 1033 | core.playback.play().get() 1034 | assert core.playback.get_state().get() == PLAYING 1035 | assert core.playback.get_current_track().get().uri == "dummy:a" 1036 | 1037 | player.OpenUri("dummy:/test/uri") 1038 | 1039 | assert core.playback.get_state().get() == PLAYING 1040 | assert core.playback.get_current_track().get().uri == "dummy:/test/uri" 1041 | --------------------------------------------------------------------------------