├── src └── mpris_server │ ├── py.typed │ ├── __init__.py │ ├── interfaces │ ├── __init__.py │ ├── interface.py │ ├── playlists.py │ ├── tracklist.py │ ├── root.py │ └── player.py │ ├── types.py │ ├── mpris │ ├── __init__.py │ ├── compat.py │ └── metadata.py │ ├── enums.py │ ├── server.py │ ├── events.py │ ├── adapters.py │ ├── base.py │ └── assets │ └── apache-2.0.txt ├── .github ├── FUNDING.yml └── workflows │ └── build.yml ├── NOTICE ├── pyproject.toml ├── README.md └── LICENSE /src/mpris_server/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [alexdelorenzo] 4 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | All changes are released under the LGPLv3, with Alex DeLorenzo as the copyright holder. 2 | You can message me for alternative licensing. 3 | 4 | Mopidy-MPRIS is licensed under the Apache 2.0 license, which can be read in full in assets/apache-2.0.txt. 5 | Stein Magnus Jodal is the author of Mopidy-MPRIS, and other contributors can be found at https://github.com/mopidy/mopidy-mpris/graphs/contributors. 6 | -------------------------------------------------------------------------------- /src/mpris_server/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Final 4 | 5 | from . import adapters, base, interfaces, mpris, server, types 6 | 7 | from .adapters import * 8 | from .base import * 9 | from .enums import * 10 | from .events import * 11 | from .interfaces import * 12 | from .mpris import * 13 | from .server import * 14 | 15 | 16 | __version__: Final[str] = '0.9.0' 17 | -------------------------------------------------------------------------------- /src/mpris_server/interfaces/__init__.py: -------------------------------------------------------------------------------- 1 | from .interface import MprisInterface 2 | from .player import Player 3 | from .playlists import Playlists 4 | from .root import Root, get_desktop_entry 5 | from .tracklist import TrackList 6 | 7 | from . import interface, player, playlists, root, tracklist 8 | 9 | 10 | __all__ = [ 11 | 'get_desktop_entry', 12 | 'interface', 13 | 'MprisInterface', 14 | 'Player', 15 | 'player', 16 | 'Playlists', 17 | 'playlists', 18 | 'Root', 19 | 'root', 20 | 'TrackList', 21 | 'tracklist', 22 | ] 23 | -------------------------------------------------------------------------------- /src/mpris_server/types.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Final, GenericAlias, _GenericAlias, get_origin 4 | 5 | 6 | ORIGIN: Final[str] = '__origin__' 7 | 8 | 9 | type GenericAliases = GenericAlias | _GenericAlias 10 | 11 | 12 | def is_type(obj: type) -> bool: 13 | return isinstance(obj, type) or bool(get_origin(obj)) 14 | 15 | 16 | def is_generic(obj: type) -> bool: 17 | return hasattr(obj, ORIGIN) or bool(get_origin(obj)) 18 | 19 | 20 | def get_type(obj: type) -> type | None: 21 | if hasattr(obj, ORIGIN): 22 | return getattr(obj, ORIGIN) 23 | 24 | if origin := get_origin(obj): 25 | return origin 26 | 27 | if isinstance(obj, type): 28 | return obj 29 | 30 | return None 31 | -------------------------------------------------------------------------------- /src/mpris_server/mpris/__init__.py: -------------------------------------------------------------------------------- 1 | from . import compat, metadata 2 | 3 | from .compat import enforce_dbus_length, get_dbus_name, get_track_id, DBUS_NAME_MAX 4 | from .metadata import ( 5 | DEFAULT_METADATA, Name, Metadata, MetadataEntry, NameMetadata, SortedMetadata, 6 | MetadataEntries, MetadataTypes, MetadataObj, ValidMetadata, get_runtime_types, 7 | is_dbus_type, is_valid_metadata, get_dbus_metadata 8 | ) 9 | 10 | 11 | __all__ = [ 12 | 'compat', 13 | 'DBUS_NAME_MAX', 14 | 'DEFAULT_METADATA', 15 | 'enforce_dbus_length', 16 | 'get_dbus_metadata', 17 | 'get_dbus_name', 18 | 'get_runtime_types', 19 | 'get_track_id', 20 | 'is_dbus_type', 21 | 'is_valid_metadata', 22 | 'metadata', 23 | 'Metadata', 24 | 'MetadataEntries', 25 | 'MetadataEntry', 26 | 'MetadataObj', 27 | 'MetadataTypes', 28 | 'Name', 29 | 'NameMetadata', 30 | 'SortedMetadata', 31 | 'ValidMetadata', 32 | ] 33 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | build-linux: 10 | runs-on: ubuntu-24.04 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: '3.12' 20 | 21 | - name: Install the latest version of Rye 22 | uses: eifinger/setup-rye@v4 23 | 24 | - name: Sync Rye 25 | run: | 26 | rye sync 27 | 28 | - name: Create build 29 | run: | 30 | rye build 31 | 32 | - name: Create GitHub Release 33 | uses: softprops/action-gh-release@v2 34 | if: startsWith(github.ref, 'refs/tags/') 35 | 36 | with: 37 | body: "To be updated" 38 | 39 | files: | 40 | dist/*.tar.gz 41 | dist/*.whl 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mpris_server" 3 | version = "0.10.0" 4 | description = "⏯️ Publish a MediaPlayer2 MPRIS device to D-Bus." 5 | readme = "README.md" 6 | readme-content-type = "text/markdown" 7 | license = { text = "LGPL-3.0-only" } 8 | authors = [ 9 | { name = "Alex DeLorenzo", email = "projects@alexdelorenzo.dev" } 10 | ] 11 | urls = { "Homepage" = "https://github.com/alexdelorenzo/mpris_server" } 12 | requires-python = ">=3.12" 13 | dependencies = [ 14 | "emoji>=2.8.0,<3.0.0", 15 | "pydbus>=0.6.0,<0.7.0", 16 | "PyGObject>=3.34.0", 17 | "StrEnum>=0.4.15", # StrEnum preserves strings' case unlike enum.StrEnum 18 | "unidecode>=1.3.7,<2.0.0", 19 | ] 20 | 21 | [build-system] 22 | requires = ["hatchling"] 23 | build-backend = "hatchling.build" 24 | 25 | [tool.rye] 26 | managed = true 27 | dev-dependencies = [] 28 | 29 | [tool.hatch.metadata] 30 | allow-direct-references = true 31 | 32 | [tool.hatch.build.targets.wheel] 33 | packages = ["src/mpris_server"] 34 | -------------------------------------------------------------------------------- /src/mpris_server/interfaces/interface.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from abc import ABC 5 | from functools import wraps 6 | from typing import ClassVar, Final, Self, TYPE_CHECKING 7 | 8 | from pydbus.generic import signal 9 | 10 | from ..base import Interface, Method, NAME 11 | 12 | 13 | if TYPE_CHECKING: 14 | from ..adapters import MprisAdapter 15 | 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | def log_trace[S: Self, **P, T](method: Method) -> Method: 21 | @wraps(method) 22 | def new_method(self: S, *args: P.args, **kwargs: P.kwargs) -> T: 23 | name = method.__name__ 24 | func = f'{self.INTERFACE}.{name}()' 25 | 26 | log.debug(f'{func} called.') 27 | 28 | if (result := method(self, *args, **kwargs)) is not None: 29 | log.debug(f'{func} result: {result}') 30 | 31 | return result 32 | 33 | return new_method 34 | 35 | 36 | class MprisInterface[A: MprisAdapter](ABC): 37 | INTERFACE: ClassVar[Interface] = Interface.Root 38 | 39 | name: str 40 | adapter: A | None 41 | 42 | PropertiesChanged: Final[signal] = signal() 43 | 44 | def __init__(self, name: str = NAME, adapter: A | None = None): 45 | self.name = name 46 | self.adapter = adapter 47 | -------------------------------------------------------------------------------- /src/mpris_server/mpris/compat.py: -------------------------------------------------------------------------------- 1 | # Python and DBus compatibility 2 | # See: https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata/ 3 | from __future__ import annotations 4 | 5 | from collections.abc import Callable, Sequence 6 | from functools import wraps 7 | from random import choices 8 | from typing import Final 9 | 10 | from emoji import demojize, emoji_count 11 | from unidecode import unidecode 12 | 13 | from ..base import DbusObj, NAME_PREFIX, RAND_CHARS, VALID_CHARS 14 | 15 | 16 | DBUS_NAME_MAX: Final[int] = 255 17 | START_WITH: Final[str] = "_" 18 | FIRST_CHAR: Final[int] = 0 19 | 20 | # following must be subscriptable to be used with random.choices() 21 | VALID_CHARS_SUB: Final[Sequence[str]] = tuple(VALID_CHARS) 22 | INTERFACE_CHARS: Final[set[str]] = {*VALID_CHARS, '-'} 23 | 24 | 25 | type ReturnsStr[**P] = Callable[P, str] 26 | 27 | 28 | def to_ascii(text: str) -> str: 29 | if emoji_count(text): 30 | text = demojize(text) 31 | 32 | return unidecode(text) 33 | 34 | 35 | def random_name() -> str: 36 | chars = choices(VALID_CHARS_SUB, k=RAND_CHARS) 37 | name = ''.join(chars) 38 | 39 | return NAME_PREFIX + name 40 | 41 | 42 | def enforce_dbus_length[**P](func: ReturnsStr) -> ReturnsStr: 43 | @wraps(func) 44 | def new_func(*args: P.args, **kwargs: P.kwargs) -> str: 45 | val: str = func(*args, **kwargs) 46 | return val[:DBUS_NAME_MAX] 47 | 48 | return new_func 49 | 50 | 51 | @enforce_dbus_length 52 | def get_dbus_name( 53 | name: str | None = None, 54 | is_interface: bool = False, 55 | ) -> str: 56 | if not name: 57 | return get_dbus_name(random_name()) 58 | 59 | # interface names can contain hyphens 60 | valid_chars = INTERFACE_CHARS if is_interface else VALID_CHARS 61 | 62 | # convert utf8 to ascii 63 | new_name = to_ascii(name) 64 | 65 | # new name shouldn't have spaces 66 | new_name = new_name.replace(' ', '_') 67 | 68 | # new name should only contain D-Bus valid chars 69 | new_name = ''.join( 70 | char 71 | for char in new_name 72 | if char in valid_chars 73 | ) 74 | 75 | # D-Bus names can't start with numbers 76 | if new_name and new_name[FIRST_CHAR].isnumeric(): 77 | # just stick an underscore in front of the number 78 | return START_WITH + new_name 79 | 80 | # but they can start with letters or underscores 81 | elif new_name: 82 | return new_name 83 | 84 | # if there is no name left after normalizing, 85 | # then make a random one and validate it 86 | return get_dbus_name(random_name()) 87 | 88 | 89 | @enforce_dbus_length 90 | def get_track_id(name: str) -> DbusObj: 91 | return f'/track/{get_dbus_name(name)}' 92 | -------------------------------------------------------------------------------- /src/mpris_server/interfaces/playlists.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import ClassVar, Final 5 | 6 | from pydbus.generic import signal 7 | 8 | from .interface import MprisInterface, log_trace 9 | from ..base import ActivePlaylist, DbusTypes, Interface, Ordering, PlaylistEntry, PlaylistId 10 | from ..enums import Access, Arg, Direction, Method, Property, Signal 11 | 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | 16 | class Playlists(MprisInterface): 17 | INTERFACE: ClassVar[Interface] = Interface.Playlists 18 | 19 | __doc__: Final[str] = f""" 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | """ 43 | 44 | PlaylistChanged: Final[signal] = signal() 45 | 46 | @property 47 | @log_trace 48 | def ActivePlaylist(self) -> ActivePlaylist: 49 | return self.adapter.get_active_playlist() 50 | 51 | @property 52 | @log_trace 53 | def Orderings(self) -> list[Ordering]: 54 | return self.adapter.get_orderings() 55 | 56 | @property 57 | @log_trace 58 | def PlaylistCount(self) -> int: 59 | return self.adapter.get_playlist_count() 60 | 61 | @log_trace 62 | def ActivatePlaylist(self, playlist_id: PlaylistId): 63 | self.adapter.activate_playlist(playlist_id) 64 | 65 | @log_trace 66 | def GetPlaylists(self, index: int, max_count: int, order: str, reverse: bool) -> list[PlaylistEntry]: 67 | return self.adapter.get_playlists(index, max_count, order, reverse) 68 | -------------------------------------------------------------------------------- /src/mpris_server/enums.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import auto 4 | 5 | from strenum import LowercaseStrEnum, StrEnum 6 | 7 | 8 | __all__ = [ 9 | 'Access', 10 | 'Arg', 11 | 'BusType', 12 | 'Direction', 13 | 'LoopStatus', 14 | 'Method', 15 | 'Property', 16 | 'Signal', 17 | ] 18 | 19 | 20 | class Access(LowercaseStrEnum): 21 | READ = auto() 22 | READWRITE = auto() 23 | 24 | 25 | class Arg(StrEnum): 26 | AfterTrack = auto() 27 | CurrentTrack = auto() 28 | Index = auto() 29 | MaxCount = auto() 30 | Metadata = auto() 31 | Offset = auto() 32 | Order = auto() 33 | Playlist = auto() 34 | PlaylistId = auto() 35 | Playlists = auto() 36 | Position = auto() 37 | ReverseOrder = auto() 38 | SetAsCurrent = auto() 39 | TrackId = auto() 40 | TrackIds = auto() 41 | Tracks = auto() 42 | Uri = auto() 43 | 44 | 45 | class BusType(LowercaseStrEnum): 46 | SESSION = auto() 47 | SYSTEM = auto() 48 | DEFAULT = SESSION 49 | 50 | 51 | class Direction(LowercaseStrEnum): 52 | IN = auto() 53 | OUT = auto() 54 | 55 | 56 | class LoopStatus(StrEnum): 57 | NONE = 'None' 58 | TRACK = 'Track' 59 | PLAYLIST = 'Playlist' 60 | 61 | 62 | class Method(StrEnum): 63 | ActivatePlaylist = auto() 64 | AddTrack = auto() 65 | GetPlaylists = auto() 66 | GetTracksMetadata = auto() 67 | GoTo = auto() 68 | Next = auto() 69 | OpenUri = auto() 70 | Pause = auto() 71 | Play = auto() 72 | PlayPause = auto() 73 | Previous = auto() 74 | Quit = auto() 75 | Raise = auto() 76 | RemoveTrack = auto() 77 | Seek = auto() 78 | SetPosition = auto() 79 | Stop = auto() 80 | 81 | 82 | class Property(StrEnum): 83 | ActivePlaylist = auto() 84 | CanControl = auto() 85 | CanEditTracks = auto() 86 | CanGoNext = auto() 87 | CanGoPrevious = auto() 88 | CanPause = auto() 89 | CanPlay = auto() 90 | CanQuit = auto() 91 | CanRaise = auto() 92 | CanSeek = auto() 93 | CanSetFullscreen = auto() 94 | DesktopEntry = auto() 95 | Fullscreen = auto() 96 | HasTrackList = auto() 97 | Identity = auto() 98 | LoopStatus = auto() 99 | MaximumRate = auto() 100 | Metadata = auto() 101 | MinimumRate = auto() 102 | Orderings = auto() 103 | PlaybackStatus = auto() 104 | PlaylistCount = auto() 105 | Position = auto() 106 | Rate = auto() 107 | Shuffle = auto() 108 | SupportedMimeTypes = auto() 109 | SupportedUriSchemes = auto() 110 | Tracks = auto() 111 | Volume = auto() 112 | 113 | 114 | class Signal(StrEnum): 115 | PlaylistChanged = auto() 116 | PropertiesChanged = auto() 117 | Seeked = auto() 118 | TrackAdded = auto() 119 | TrackListReplaced = auto() 120 | TrackMetadataChanged = auto() 121 | TrackRemoved = auto() 122 | -------------------------------------------------------------------------------- /src/mpris_server/interfaces/tracklist.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import ClassVar, Final 4 | 5 | from pydbus.generic import signal 6 | 7 | from .interface import MprisInterface 8 | from ..base import DbusObj, DbusTypes, Interface, NoTrack 9 | from ..enums import Access, Arg, Direction, Method, Property, Signal 10 | from ..mpris.metadata import Metadata 11 | 12 | 13 | class TrackList(MprisInterface): 14 | INTERFACE: ClassVar[Interface] = Interface.TrackList 15 | 16 | __doc__: Final[str] = f""" 17 | 18 | 19 | 20 | 21 | 22 | 23 | 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 | TrackAdded: Final[signal] = signal() 58 | TrackListReplaced: Final[signal] = signal() 59 | TrackMetadataChanged: Final[signal] = signal() 60 | TrackRemoved: Final[signal] = signal() 61 | 62 | @property 63 | def CanEditTracks(self) -> bool: 64 | return self.adapter.can_edit_tracks() 65 | 66 | @property 67 | def Tracks(self) -> list[DbusObj]: 68 | if not (tracks := self.adapter.get_tracks()): 69 | return [NoTrack] 70 | 71 | return tracks 72 | 73 | def AddTrack( 74 | self, 75 | uri: str, 76 | after_track: DbusObj, 77 | set_as_current: bool 78 | ): 79 | self.adapter.add_track(uri, after_track, set_as_current) 80 | 81 | def GetTracksMetadata(self, track_ids: list[DbusObj]) -> list[Metadata]: 82 | return self.adapter.get_tracks_metadata(track_ids) 83 | 84 | def GoTo(self, track_id: DbusObj): 85 | self.adapter.go_to(track_id) 86 | 87 | def RemoveTrack(self, track_id: DbusObj): 88 | self.adapter.remove_track(track_id) 89 | 90 | 91 | 92 | -------------------------------------------------------------------------------- /src/mpris_server/interfaces/root.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import PurePath 4 | from typing import ClassVar, Final 5 | 6 | from .interface import MprisInterface, log_trace 7 | from ..base import DbusTypes, Interface, Paths 8 | from ..enums import Access, Method, Property 9 | 10 | 11 | NO_SUFFIX: Final[str] = '' 12 | DESKTOP_EXT: Final[str] = '.desktop' 13 | NO_DESKTOP_ENTRY: Final[str] = '' 14 | 15 | 16 | class Root(MprisInterface): 17 | INTERFACE: ClassVar[Interface] = Interface.Root 18 | 19 | __doc__: Final[str] = f""" 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | """ 37 | 38 | @property 39 | @log_trace 40 | def CanQuit(self) -> bool: 41 | return self.adapter.can_quit() 42 | 43 | @property 44 | @log_trace 45 | def CanRaise(self) -> bool: 46 | return self.adapter.can_raise() 47 | 48 | @property 49 | @log_trace 50 | def CanSetFullscreen(self) -> bool: 51 | return self.adapter.can_fullscreen() 52 | 53 | @property 54 | @log_trace 55 | def DesktopEntry(self) -> str: 56 | path: Paths = self.adapter.get_desktop_entry() 57 | return get_desktop_entry(path) 58 | 59 | @property 60 | @log_trace 61 | def Fullscreen(self) -> bool: 62 | return self.adapter.get_fullscreen() 63 | 64 | @Fullscreen.setter 65 | @log_trace 66 | def Fullscreen(self, value: bool): 67 | self.adapter.set_fullscreen(value) 68 | 69 | @property 70 | @log_trace 71 | def HasTrackList(self) -> bool: 72 | return self.adapter.has_tracklist() 73 | 74 | @property 75 | @log_trace 76 | def Identity(self) -> str: 77 | return self.name 78 | 79 | @property 80 | @log_trace 81 | def SupportedMimeTypes(self) -> list[str]: 82 | return self.adapter.get_mime_types() 83 | 84 | @property 85 | @log_trace 86 | def SupportedUriSchemes(self) -> list[str]: 87 | return self.adapter.get_uri_schemes() 88 | 89 | @log_trace 90 | def Quit(self): 91 | self.adapter.quit() 92 | 93 | @log_trace 94 | def Raise(self): 95 | self.adapter.set_raise(True) 96 | 97 | 98 | def get_desktop_entry(path: Paths | None) -> str: 99 | if not path: 100 | return NO_DESKTOP_ENTRY 101 | 102 | # mpris requires stripped suffix 103 | if isinstance(path, PurePath): 104 | path = path.with_suffix(NO_SUFFIX) 105 | 106 | name = str(path) 107 | 108 | if name.endswith(DESKTOP_EXT): 109 | name = name.rstrip(DESKTOP_EXT) 110 | 111 | return name 112 | -------------------------------------------------------------------------------- /src/mpris_server/server.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from collections.abc import Iterable 5 | from threading import Thread 6 | from typing import Final 7 | from weakref import finalize 8 | 9 | from gi.repository import GLib 10 | from pydbus import SessionBus, SystemBus 11 | from pydbus.bus import Bus 12 | from pydbus.publication import Publication 13 | 14 | from .adapters import MprisAdapter 15 | from .base import DBUS_PATH, Interface, NAME 16 | from .enums import BusType 17 | from .events import EventAdapter 18 | from .interfaces.interface import MprisInterface 19 | from .interfaces.player import Player 20 | from .interfaces.playlists import Playlists 21 | from .interfaces.root import Root 22 | from .interfaces.tracklist import TrackList 23 | from .mpris.compat import get_dbus_name 24 | 25 | 26 | __all__ = [ 27 | 'DEFAULT_BUS_TYPE', # for backwards compatibility 28 | 'BusType', # for backwards compatibility 29 | 'Server', 30 | ] 31 | 32 | log = logging.getLogger(__name__) 33 | 34 | DEFAULT_BUS_TYPE: Final[BusType] = BusType.SESSION 35 | NOW: Final[int] = 0 36 | 37 | 38 | class Server[A: MprisAdapter, E: EventAdapter, I: MprisInterface]: 39 | name: str 40 | adapter: A | None 41 | events: E | None 42 | 43 | root: Root 44 | player: Player 45 | playlists: Playlists 46 | tracklist: TrackList 47 | interfaces: tuple[I, ...] 48 | 49 | dbus_name: str 50 | 51 | _loop: GLib.MainLoop | None 52 | _publication_token: Publication | None 53 | _thread: Thread | None 54 | 55 | def __init__( 56 | self, 57 | name: str = NAME, 58 | adapter: A | None = None, 59 | events: E | None = None, 60 | *interfaces: I, 61 | ): 62 | self.name = name 63 | self.adapter = adapter 64 | 65 | self.root = Root(self.name, self.adapter) 66 | self.player = Player(self.name, self.adapter) 67 | self.playlists = Playlists(self.name, self.adapter) 68 | self.tracklist = TrackList(self.name, self.adapter) 69 | self.interfaces = self.root, self.player, self.playlists, self.tracklist, *interfaces 70 | 71 | self.dbus_name = get_dbus_name(self.name) 72 | 73 | self._loop = None 74 | self._publication_token = None 75 | self._thread = None 76 | 77 | self.set_event_adapter(events) 78 | 79 | finalize(self, self.__del__) 80 | 81 | def __del__(self): 82 | self.quit() 83 | 84 | def _get_dbus_paths(self) -> Iterable[tuple[str, I]]: 85 | for interface in self.interfaces: 86 | yield DBUS_PATH, interface 87 | 88 | def _run_loop(self): 89 | self._loop = GLib.MainLoop() 90 | 91 | try: 92 | self._loop.run() 93 | 94 | finally: 95 | self.quit_loop() 96 | 97 | def set_event_adapter(self, events: E): 98 | self.events = events 99 | 100 | def publish(self, bus_type: BusType = BusType.DEFAULT): 101 | log.debug(f'Connecting to D-Bus {bus_type} bus...') 102 | bus: Bus 103 | 104 | match bus_type: 105 | case BusType.DEFAULT: 106 | bus = SessionBus() 107 | 108 | case BusType.SESSION: 109 | bus = SessionBus() 110 | 111 | case BusType.SYSTEM: 112 | bus = SystemBus() 113 | 114 | case _: 115 | log.warning(f'Invalid bus "{bus_type}", using {BusType.DEFAULT}.') 116 | bus = SessionBus() 117 | 118 | log.debug(f'MPRIS server connecting to D-Bus {bus_type} bus.') 119 | name = f'{Interface.Root}.{self.dbus_name}' 120 | paths = self._get_dbus_paths() 121 | 122 | self._publication_token = bus.publish(name, *paths) 123 | log.info(f'Published {name} to D-Bus {bus_type} bus.') 124 | 125 | def unpublish(self): 126 | if self._publication_token: 127 | log.debug('Unpublishing MPRIS interface.') 128 | 129 | self._publication_token.unpublish() 130 | self._publication_token = None 131 | 132 | def loop(self, bus_type: BusType = BusType.DEFAULT, background: bool = False): 133 | if not self._publication_token: 134 | self.publish(bus_type) 135 | 136 | if background: 137 | log.debug("Entering D-Bus loop in background thread.") 138 | self._thread = Thread(target=self._run_loop, name=self.name, daemon=True) 139 | self._thread.start() 140 | 141 | else: 142 | log.debug("Entering D-Bus loop in foreground thread.") 143 | self._run_loop() 144 | 145 | def quit_loop(self): 146 | try: 147 | if self._loop: 148 | log.debug('Quitting GLib loop.') 149 | self._loop.quit() 150 | self._loop = None 151 | 152 | finally: 153 | if self._thread: 154 | log.debug("Joining background thread.") 155 | self._thread.join(timeout=NOW) 156 | self._thread = None 157 | 158 | def quit(self): 159 | log.debug('Unpublishing and quitting loop.') 160 | self.unpublish() 161 | self.quit_loop() 162 | -------------------------------------------------------------------------------- /src/mpris_server/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC, abstractmethod 4 | from typing import override 5 | 6 | from .base import Changes, DbusObj, ON_ENDED_PROPS, ON_OPTION_PROPS, ON_PLAYBACK_PROPS, ON_PLAYER_PROPS, \ 7 | ON_PLAYLIST_PROPS, ON_PLAYPAUSE_PROPS, ON_ROOT_PROPS, ON_SEEK_PROPS, ON_TITLE_PROPS, ON_TRACKS_PROPS, ON_VOLUME_PROPS, \ 8 | Position, dbus_emit_changes 9 | from .interfaces.interface import MprisInterface 10 | from .interfaces.player import Player 11 | from .interfaces.playlists import Playlists 12 | from .interfaces.root import Root 13 | from .interfaces.tracklist import TrackList 14 | from .mpris.metadata import Metadata 15 | 16 | 17 | __all__ = [ 18 | 'BaseEventAdapter', 19 | 'Changes', 20 | 'EventAdapter', 21 | 'PlayerEventAdapter', 22 | 'PlaylistsEventAdapter', 23 | 'RootEventAdapter', 24 | 'TracklistEventAdapter', 25 | ] 26 | 27 | 28 | class BaseEventAdapter(ABC): 29 | root: Root 30 | player: Player | None 31 | playlist: Playlists | None 32 | tracklist: TrackList | None 33 | 34 | def __init__( 35 | self, 36 | root: Root, 37 | player: Player | None = None, 38 | playlists: Playlists | None = None, 39 | tracklist: TrackList | None = None, 40 | ): 41 | self.root = root 42 | self.player = player 43 | self.playlist = playlists 44 | self.tracklist = tracklist 45 | 46 | @staticmethod 47 | def emit_changes[I: MprisInterface](interface: I, changes: Changes): 48 | dbus_emit_changes(interface, changes) 49 | 50 | @abstractmethod 51 | def emit_all(self): 52 | """Emit all changes for all adapters in hierarchy""" 53 | pass 54 | 55 | 56 | class RootEventAdapter(BaseEventAdapter, ABC): 57 | @override 58 | def emit_all(self): 59 | self.on_root_all() 60 | super().emit_all() 61 | 62 | def emit_root_changes(self, changes: Changes): 63 | self.emit_changes(self.root, changes) 64 | 65 | def on_root_all(self): 66 | self.emit_root_changes(ON_ROOT_PROPS) 67 | 68 | 69 | class PlayerEventAdapter(BaseEventAdapter, ABC): 70 | @override 71 | def emit_all(self): 72 | self.on_player_all() 73 | super().emit_all() 74 | 75 | def on_player_all(self): 76 | self.emit_player_changes(ON_PLAYER_PROPS) 77 | 78 | def emit_player_changes(self, changes: Changes): 79 | self.emit_changes(self.player, changes) 80 | 81 | def on_ended(self): 82 | self.emit_player_changes(ON_ENDED_PROPS) 83 | 84 | def on_volume(self): 85 | self.emit_player_changes(ON_VOLUME_PROPS) 86 | 87 | def on_playback(self): 88 | self.emit_player_changes(ON_PLAYBACK_PROPS) 89 | 90 | def on_playpause(self): 91 | self.emit_player_changes(ON_PLAYPAUSE_PROPS) 92 | 93 | def on_title(self): 94 | self.emit_player_changes(ON_TITLE_PROPS) 95 | 96 | def on_seek(self, position: Position): 97 | self.player.Seeked(position) 98 | self.emit_player_changes(ON_SEEK_PROPS) 99 | 100 | def on_options(self): 101 | self.emit_player_changes(ON_OPTION_PROPS) 102 | 103 | 104 | class PlaylistsEventAdapter(BaseEventAdapter, ABC): 105 | @override 106 | def emit_all(self): 107 | self.on_playlists_all() 108 | super().emit_all() 109 | 110 | def emit_playlist_changes(self, changes: Changes): 111 | self.emit_changes(self.playlist, changes) 112 | 113 | def on_playlists_all(self): 114 | self.emit_playlist_changes(ON_PLAYLIST_PROPS) 115 | 116 | def on_playlist_change(self, playlist_id: DbusObj): 117 | self.playlist.PlaylistChanged(playlist_id) 118 | self.emit_playlist_changes(ON_PLAYLIST_PROPS) 119 | 120 | 121 | class TracklistEventAdapter(BaseEventAdapter, ABC): 122 | @override 123 | def emit_all(self): 124 | self.on_tracklist_all() 125 | super().emit_all() 126 | 127 | def emit_tracklist_changes(self, changes: Changes): 128 | self.emit_changes(self.tracklist, changes) 129 | 130 | def on_tracklist_all(self): 131 | self.emit_tracklist_changes(ON_TRACKS_PROPS) 132 | 133 | def on_list_replaced(self, tracks: list[DbusObj], current_track: DbusObj): 134 | self.tracklist.TrackListReplaced(tracks, current_track) 135 | self.emit_tracklist_changes(ON_TRACKS_PROPS) 136 | 137 | def on_track_added(self, metadata: Metadata, after_track: DbusObj): 138 | self.tracklist.TrackAdded(metadata, after_track) 139 | self.emit_tracklist_changes(ON_TRACKS_PROPS) 140 | 141 | def on_track_removed(self, track_id: DbusObj): 142 | self.tracklist.TrackRemoved(track_id) 143 | self.emit_tracklist_changes(ON_TRACKS_PROPS) 144 | 145 | def on_track_metadata_change(self, track_id: DbusObj, metadata: Metadata): 146 | self.tracklist.TrackMetadataChanged(track_id, metadata) 147 | self.emit_tracklist_changes(ON_TRACKS_PROPS) 148 | 149 | 150 | class EventAdapter( 151 | RootEventAdapter, 152 | PlayerEventAdapter, 153 | TracklistEventAdapter, 154 | PlaylistsEventAdapter, 155 | ABC, 156 | ): 157 | ''' 158 | Notify D-Bus of state-change events in the media player. 159 | 160 | Implement this class and integrate it in your application to emit 161 | D-Bus signals when there are state changes in the media player. 162 | ''' 163 | pass 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ▶️ Add MPRIS integration to media players 2 | 3 | `mpris_server` provides adapters to integrate [MPRIS](https://specifications.freedesktop.org/mpris-spec/2.2/) support in 4 | your media player or device. By supporting MPRIS in your app, you will allow Linux users to control all aspects of 5 | playback from the media controllers they already have installed. 6 | 7 | Whereas [existing MPRIS libraries for Python](https://github.com/hugosenari/mpris2) implement clients for apps with 8 | existing MPRIS support, `mpris_server` is a library used to implement MPRIS support in apps that don't already have it. 9 | If you want to give your media player an MPRIS interface, then `mpris_server` is right for you. 10 | 11 | Check out [`📺 cast_control`](https://github.com/alexdelorenzo/cast_control) for an app that uses `mpris_server`. 12 | 13 | `mpris_server` is a fork of [Mopidy-MPRIS](https://github.com/mopidy/mopidy-mpris) that was extended and made into a 14 | general purpose library. 15 | 16 | ## Features 17 | 18 | Implements the following from the [MPRIS specification](https://specifications.freedesktop.org/mpris-spec/2.2/): 19 | 20 | * [x] MediaPlayer2 21 | * [x] MediaPlayer2.Player 22 | * [x] MediaPlayer2.Playlist 23 | * [x] MediaPlayer2.TrackList 24 | 25 | The library also provides an event handler that emits `org.freedesktop.DBus.Properties.PropertiesChanged` in response to 26 | changes in your media player. This allows for real-time updates from your media player to D-Bus. 27 | 28 | ## Installation 29 | 30 | ### Requirements 31 | 32 | - Linux / *BSD / [macOS](https://github.com/zbentley/dbus-osx-examples) 33 | - [D-Bus](https://www.freedesktop.org/wiki/Software/dbus/) 34 | - Python >= 3.7 35 | - [PyGObject](https://pypi.org/project/PyGObject/) 36 | - See `project.dependencies` in `pyproject.toml` 37 | - Rye 38 | 39 | #### Installing PyGObject 40 | 41 | On Debian-derived distributions like Ubuntu, install `python3-gi` with `apt`. On Arch, you'll want to 42 | install `python-gobject`. 43 | 44 | On macOS, install [`pygobject3`](https://formulae.brew.sh/formula/pygobject3) via `brew`. Note that `mpris_server` on 45 | macOS hasn't been tested, but is theoretically possible to use. 46 | 47 | Use `pip` to install `PyGObject>=3.34.0` if there are no installation candidates available in your vendor's package 48 | repositories. 49 | 50 | ### PyPI 51 | 52 | `pip3 install mpris_server` 53 | 54 | ### GitHub 55 | 56 | Clone the repo, run `pip3 install -r requirements.txt`, followed by `python3 setup.py install`. 57 | 58 | ## Usage 59 | 60 | ### Implement `adapters.MprisAdapter` 61 | 62 | Subclass `adapters.MprisAdapter` and implement each method. 63 | 64 | After subclassing, pass an instance to an instance of `server.Server`. 65 | 66 | ### Implement `events.EventAdapter` 67 | 68 | Subclass `adapters.EventAdapter`. This interface has a good default implementation, only override its methods if your 69 | app calls for it. 70 | 71 | If you choose to re-implement its methods, call `emit_changes()` with the corresponding interface and a `List[str]` 72 | of [properties](https://specifications.freedesktop.org/mpris-spec/2.2/Player_Interface.html) that changed. 73 | 74 | Integrate the adapter with your application to emit changes in your media player that DBus needs to know about. For 75 | example, if the user pauses the media player, be sure to call `EventAdapter.on_playpause()` in the app. DBus won't know 76 | about the change otherwise. 77 | 78 | ### Create the Server and Publish 79 | 80 | Create an instance of `server.Server`, pass it an instance of your `MprisAdapter`, and call `publish()` to publish your 81 | media player via DBus. 82 | 83 | ```python3 84 | mpris = Server('MyMediaPlayer', adapter=my_adapter) 85 | mpris.publish() 86 | ``` 87 | 88 | Call `loop()` to enter the DBus event loop, or enter the DBus event loop elsewhere in your code. 89 | 90 | ```python3 91 | mpris.loop() 92 | ``` 93 | 94 | Or: 95 | 96 | ```python3 97 | from gi.repository import GLib 98 | 99 | 100 | loop = GLib.MainLoop() 101 | loop.run() 102 | ``` 103 | 104 | ### Example 105 | 106 | ```python3 107 | from mpris_server.adapters import MprisAdapter 108 | from mpris_server.events import EventAdapter 109 | from mpris_server.server import Server 110 | from mpris_server import Metadata 111 | 112 | from my_app import app # custom app you want to integrate 113 | 114 | 115 | class MyAppAdapter(MprisAdapter): 116 | # Make sure to implement all methods on MprisAdapter, not just metadata() 117 | def metadata(self) -> Metadata: 118 | ... 119 | # and so on 120 | 121 | 122 | class MyAppEventHandler(EventAdapter): 123 | # EventAdapter has good default implementations for its methods. 124 | # Only override the default methods if it suits your app. 125 | 126 | def on_app_event(self, event: str): 127 | # trigger DBus updates based on events in your app 128 | if event == 'pause': 129 | self.on_playpause() 130 | ... 131 | # and so on 132 | 133 | 134 | # create mpris adapter and initialize mpris server 135 | my_adapter = MyAppAdapter() 136 | mpris = Server('MyApp', adapter=my_adapter) 137 | 138 | # initialize app integration with mpris 139 | event_handler = MyAppEventHandler(root=mpris.root, player=mpris.player) 140 | app.register_event_handler(event_handler) 141 | 142 | # publish and serve 143 | mpris.loop() 144 | ``` 145 | 146 | ## Support 147 | 148 | Want to support this project and [other open-source projects](https://github.com/alexdelorenzo) like it? 149 | 150 | Buy Me A Coffee 151 | 152 | ## License 153 | 154 | `mpris_server` is released under the AGPLv3, see [`LICENSE`](/LICENSE). Message me if you'd like to use this project 155 | with a different license. 156 | -------------------------------------------------------------------------------- /src/mpris_server/adapters.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from abc import ABC 4 | from typing import Final 5 | 6 | from .base import ActivePlaylist, DEFAULT_DESKTOP, DEFAULT_ORDERINGS, DEFAULT_PLAYLIST_COUNT, DEFAULT_RATE, DbusObj, \ 7 | MIME_TYPES, NoTrack, Ordering, Paths, PlayState, PlaylistEntry, Position, Rate, Track, URI, Volume 8 | from .enums import LoopStatus 9 | from .mpris.metadata import Metadata, ValidMetadata 10 | 11 | 12 | __all__ = [ 13 | 'MprisAdapter', 14 | 'NoTrack', 15 | 'PlayerAdapter', 16 | 'PlaylistAdapter', 17 | 'RootAdapter', 18 | 'TrackListAdapter', 19 | ] 20 | 21 | DEFAULT_ADAPTER_NAME: Final[str] = 'MprisAdapter' 22 | DEFAULT_FULLSCREEN: Final[bool] = False 23 | 24 | 25 | class RootAdapter(ABC): 26 | def can_fullscreen(self) -> bool: 27 | pass 28 | 29 | def can_quit(self) -> bool: 30 | pass 31 | 32 | def can_raise(self) -> bool: 33 | pass 34 | 35 | def get_desktop_entry(self) -> Paths: 36 | return DEFAULT_DESKTOP 37 | 38 | def get_fullscreen(self) -> bool: 39 | return DEFAULT_FULLSCREEN 40 | 41 | def get_mime_types(self) -> list[str]: 42 | return MIME_TYPES 43 | 44 | def get_uri_schemes(self) -> list[str]: 45 | return URI 46 | 47 | def has_tracklist(self) -> bool: 48 | pass 49 | 50 | def quit(self): 51 | pass 52 | 53 | def set_fullscreen(self, value: bool): 54 | pass 55 | 56 | def set_raise(self, value: bool): 57 | pass 58 | 59 | 60 | class PlayerAdapter(ABC): 61 | def metadata(self) -> ValidMetadata: 62 | """ 63 | Implement this function to supply your own MPRIS Metadata. 64 | 65 | If this function is implemented, metadata won't be built from get_current_track(). 66 | 67 | See: https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata/ 68 | """ 69 | pass 70 | 71 | def get_current_track(self) -> Track: 72 | """ 73 | This function is an artifact of forking the base MPRIS library to a generic interface. 74 | The base library expected Track-like objects to build metadata. 75 | 76 | If metadata() is implemented, this function won't be used to build MPRIS metadata. 77 | """ 78 | pass 79 | 80 | def can_control(self) -> bool: 81 | pass 82 | 83 | def can_go_next(self) -> bool: 84 | pass 85 | 86 | def can_go_previous(self) -> bool: 87 | pass 88 | 89 | def can_pause(self) -> bool: 90 | pass 91 | 92 | def can_play(self) -> bool: 93 | pass 94 | 95 | def can_seek(self) -> bool: 96 | pass 97 | 98 | def get_art_url(self, track: DbusObj | Track | None) -> str: 99 | pass 100 | 101 | def get_current_position(self) -> Position: 102 | pass 103 | 104 | def get_maximum_rate(self) -> Rate: 105 | pass 106 | 107 | def get_minimum_rate(self) -> Rate: 108 | pass 109 | 110 | def get_next_track(self) -> Track: 111 | pass 112 | 113 | def get_playstate(self) -> PlayState: 114 | pass 115 | 116 | def get_previous_track(self) -> Track: 117 | pass 118 | 119 | def get_rate(self) -> Rate: 120 | return DEFAULT_RATE 121 | 122 | def get_shuffle(self) -> bool: 123 | pass 124 | 125 | def get_stream_title(self) -> str: 126 | pass 127 | 128 | def get_volume(self) -> Volume: 129 | pass 130 | 131 | def is_mute(self) -> bool: 132 | pass 133 | 134 | def is_playlist(self) -> bool: 135 | pass 136 | 137 | def is_repeating(self) -> bool: 138 | pass 139 | 140 | def next(self): 141 | pass 142 | 143 | def open_uri(self, uri: str): 144 | pass 145 | 146 | def pause(self): 147 | pass 148 | 149 | def play(self): 150 | pass 151 | 152 | def previous(self): 153 | pass 154 | 155 | def resume(self): 156 | pass 157 | 158 | def seek(self, time: Position, track_id: DbusObj | None = None): 159 | pass 160 | 161 | def set_loop_status(self, value: LoopStatus): 162 | match value: 163 | case LoopStatus.NONE: 164 | self.set_repeating(False) 165 | 166 | case LoopStatus.TRACK | LoopStatus.PLAYLIST: 167 | self.set_repeating(True) 168 | 169 | def set_maximum_rate(self, value: Rate): 170 | pass 171 | 172 | def set_minimum_rate(self, value: Rate): 173 | pass 174 | 175 | def set_mute(self, value: bool): 176 | pass 177 | 178 | def set_rate(self, value: Rate): 179 | pass 180 | 181 | def set_repeating(self, value: bool): 182 | pass 183 | 184 | def set_shuffle(self, value: bool): 185 | pass 186 | 187 | def set_volume(self, value: Volume): 188 | pass 189 | 190 | def stop(self): 191 | pass 192 | 193 | 194 | class PlaylistAdapter(ABC): 195 | def activate_playlist(self, id: DbusObj): 196 | pass 197 | 198 | def get_active_playlist(self) -> ActivePlaylist: 199 | pass 200 | 201 | def get_orderings(self) -> list[Ordering]: 202 | return DEFAULT_ORDERINGS 203 | 204 | def get_playlist_count(self) -> int: 205 | return DEFAULT_PLAYLIST_COUNT 206 | 207 | def get_playlists(self, index: int, max_count: int, order: str, reverse: bool) -> list[PlaylistEntry]: 208 | pass 209 | 210 | 211 | class TrackListAdapter(ABC): 212 | def add_track(self, uri: str, after_track: DbusObj, set_as_current: bool): 213 | pass 214 | 215 | def can_edit_tracks(self) -> bool: 216 | pass 217 | 218 | def get_tracks(self) -> list[DbusObj]: 219 | pass 220 | 221 | def get_tracks_metadata(self, track_ids: list[DbusObj]) -> list[Metadata]: 222 | pass 223 | 224 | def go_to(self, track_id: DbusObj): 225 | pass 226 | 227 | def remove_track(self, track_id: DbusObj): 228 | pass 229 | 230 | 231 | class MprisAdapter( 232 | RootAdapter, 233 | PlayerAdapter, 234 | PlaylistAdapter, 235 | TrackListAdapter, 236 | ABC 237 | ): 238 | """ 239 | MRPRIS interface for your application. 240 | 241 | The MPRIS implementation is supplied with information 242 | returned from this adapter. 243 | """ 244 | 245 | name: str 246 | 247 | def __init__(self, name: str = DEFAULT_ADAPTER_NAME): 248 | self.name = name 249 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. -------------------------------------------------------------------------------- /src/mpris_server/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable, Collection, Iterable, Mapping, Sequence 4 | from decimal import Decimal 5 | from enum import Enum, auto 6 | from os import PathLike 7 | from string import ascii_letters, digits 8 | from typing import Concatenate, Final, NamedTuple, Self, TYPE_CHECKING, Union 9 | 10 | from strenum import StrEnum 11 | 12 | from .enums import Property 13 | from .types import GenericAliases 14 | 15 | 16 | if TYPE_CHECKING: 17 | from .interfaces.interface import MprisInterface 18 | 19 | 20 | INTERFACE: Final[str] = "org.mpris.MediaPlayer2" 21 | DBUS_PATH: Final[str] = f"/{INTERFACE.replace('.', '/')}" 22 | NAME: Final[str] = "mprisServer" 23 | 24 | NoTrack: Final[DbusObj] = f'{DBUS_PATH}/TrackList/NoTrack' 25 | 26 | type Properties = Collection[Property] 27 | 28 | 29 | MIME_TYPES: Final[list[str]] = [ 30 | "audio/mpeg", 31 | "application/ogg", 32 | "video/mpeg", 33 | ] 34 | URI: Final[list[str]] = [ 35 | "file", 36 | ] 37 | DEFAULT_DESKTOP: Final[str] = '' 38 | 39 | # typically, these are the props that D-Bus needs to be notified about 40 | # upon specific state-change events. 41 | ON_ENDED_PROPS: Final[Properties] = [ 42 | Property.PlaybackStatus, 43 | ] 44 | ON_VOLUME_PROPS: Final[Properties] = [ 45 | Property.Metadata, 46 | Property.Volume, 47 | ] 48 | ON_PLAYBACK_PROPS: Final[Properties] = [ 49 | Property.CanControl, 50 | Property.MaximumRate, 51 | Property.Metadata, 52 | Property.MinimumRate, 53 | Property.PlaybackStatus, 54 | Property.Rate, 55 | ] 56 | ON_PLAYPAUSE_PROPS: Final[Properties] = [ 57 | Property.PlaybackStatus, 58 | ] 59 | ON_TITLE_PROPS: Final[Properties] = [ 60 | Property.Metadata, 61 | ] 62 | ON_OPTION_PROPS: Final[Properties] = [ 63 | Property.CanGoNext, 64 | Property.CanGoPrevious, 65 | Property.CanPause, 66 | Property.CanPlay, 67 | Property.LoopStatus, 68 | Property.Shuffle, 69 | ] 70 | ON_SEEK_PROPS: Final[Properties] = [ 71 | Property.CanSeek, 72 | Property.Position, 73 | ] 74 | 75 | # all props for each interface 76 | ON_PLAYER_PROPS: Final[Properties] = sorted({ 77 | *ON_ENDED_PROPS, 78 | *ON_OPTION_PROPS, 79 | *ON_PLAYBACK_PROPS, 80 | *ON_PLAYPAUSE_PROPS, 81 | *ON_SEEK_PROPS, 82 | *ON_TITLE_PROPS, 83 | *ON_VOLUME_PROPS, 84 | }) 85 | ON_TRACKS_PROPS: Final[Properties] = [ 86 | Property.CanEditTracks, 87 | Property.Tracks, 88 | ] 89 | ON_PLAYLIST_PROPS: Final[Properties] = [ 90 | Property.ActivePlaylist, 91 | Property.Orderings, 92 | Property.PlaylistCount, 93 | ] 94 | ON_ROOT_PROPS: Final[Properties] = [ 95 | Property.CanQuit, 96 | Property.CanRaise, 97 | Property.CanSetFullscreen, 98 | Property.DesktopEntry, 99 | Property.Fullscreen, 100 | Property.HasTrackList, 101 | Property.Identity, 102 | Property.SupportedMimeTypes, 103 | Property.SupportedUriSchemes, 104 | ] 105 | INVALIDATED_PROPERTIES: Final[Properties] = () 106 | 107 | 108 | class Ordering(StrEnum): 109 | Alphabetical = auto() 110 | User = auto() 111 | 112 | 113 | DEFAULT_TRACK_ID: Final[str] = '/default/1' 114 | DEFAULT_TRACK_NAME: Final[str] = "Default Track" 115 | DEFAULT_TRACK_LENGTH: Final[int] = 0 116 | DEFAULT_PLAYLIST_COUNT: Final[int] = 1 117 | DEFAULT_ORDERINGS: Final[list[Ordering]] = [ 118 | Ordering.Alphabetical, 119 | Ordering.User, 120 | ] 121 | DEFAULT_ALBUM_NAME: Final[str] = "Default Album" 122 | DEFAULT_ARTIST_NAME: Final[str] = "Default Artist" 123 | NO_ARTISTS: Final[tuple[Artist, ...]] = tuple() 124 | NO_ARTIST_NAME: Final[str] = '' 125 | 126 | BEGINNING: Final[int] = 0 127 | 128 | # valid characters for a DBus name 129 | VALID_PUNC: Final[str] = '_' 130 | VALID_CHARS: Final[set[str]] = {*ascii_letters, *digits, *VALID_PUNC} 131 | 132 | NAME_PREFIX: Final[str] = "MprisServer_" 133 | RAND_CHARS: Final[int] = 5 134 | 135 | # type aliases 136 | type Paths = PathLike | str 137 | type Changes = Iterable[Property | str] 138 | 139 | # units and convenience aliases 140 | Microseconds = int 141 | Position = Microseconds 142 | Duration = Microseconds 143 | UnitInterval = Decimal 144 | Volume = UnitInterval 145 | Rate = UnitInterval 146 | 147 | type PlaylistId = str 148 | type PlaylistName = str 149 | type PlaylistIcon = str 150 | type PlaylistEntry = tuple[PlaylistId, PlaylistName, PlaylistIcon] 151 | type PlaylistValidity = bool 152 | type ActivePlaylist = tuple[PlaylistValidity, PlaylistEntry] 153 | 154 | # python, d-bus and mpris types 155 | type PyType = type | GenericAliases 156 | type DbusPyTypes = str | float | int | bool | list | Decimal 157 | type PropertyValues = dict[Property, DbusPyTypes] 158 | type DbusMetadata = dict[Property, 'Variant'] 159 | type DbusType = str 160 | type DbusObj = str 161 | 162 | type Method[S: Self, **P, T] = Callable[Concatenate[S, P], T] 163 | 164 | 165 | DEFAULT_RATE: Final[Rate] = Rate(1.0) 166 | PAUSE_RATE: Final[Rate] = Rate(0.0) 167 | MIN_RATE: Final[Rate] = Rate(1.0) 168 | MAX_RATE: Final[Rate] = Rate(1.0) 169 | 170 | MUTE_VOLUME: Final[Volume] = Volume(0.0) 171 | MAX_VOLUME: Final[Volume] = Volume(1.0) 172 | 173 | 174 | class Interface(StrEnum): 175 | Root = INTERFACE 176 | Player = f'{Root}.Player' 177 | TrackList = f'{Root}.TrackList' 178 | Playlists = f'{Root}.Playlists' 179 | 180 | 181 | class PlayState(StrEnum): 182 | PAUSED = auto() 183 | PLAYING = auto() 184 | STOPPED = auto() 185 | 186 | 187 | class DbusTypes(StrEnum): 188 | BOOLEAN: DbusType = 'b' 189 | STRING: DbusType = 's' 190 | DATETIME: DbusType = STRING 191 | DOUBLE: DbusType = 'd' 192 | INT32: DbusType = 'i' 193 | INT64: DbusType = 'x' 194 | OBJ: DbusType = 'o' 195 | UINT32: DbusType = 'u' 196 | UINT64: DbusType = 't' 197 | VARIANT: DbusType = 'v' 198 | 199 | ARRAY: DbusType = 'a' 200 | MAP: DbusType = f'{ARRAY}{{}}' 201 | OBJ_ARRAY: DbusType = f'{ARRAY}{OBJ}' 202 | STRING_ARRAY: DbusType = f'{ARRAY}{STRING}' 203 | 204 | METADATA_ENTRY: DbusType = f'{{{STRING}{VARIANT}}}' 205 | METADATA: DbusType = f'{ARRAY}{METADATA_ENTRY}' 206 | METADATA_ARRAY: DbusType = f'{ARRAY}{METADATA}' 207 | PLAYLIST: DbusType = f'({OBJ}{STRING}{STRING})' 208 | MAYBE_PLAYLIST: DbusType = f'({BOOLEAN}{PLAYLIST})' 209 | PLAYLISTS: DbusType = f'{ARRAY}{PLAYLIST}' 210 | 211 | 212 | class _MprisTypes(NamedTuple): 213 | ARRAY: PyType = Sequence 214 | BOOLEAN: PyType = bool 215 | DATETIME: PyType = str 216 | DOUBLE: PyType = float 217 | INT32: PyType = int 218 | INT64: PyType = int 219 | MAP: PyType = Mapping 220 | OBJ: PyType = str 221 | OBJ_ARRAY: PyType = Sequence[str] 222 | STRING: PyType = str 223 | STRING_ARRAY: PyType = Sequence[str] 224 | UINT32: PyType = int 225 | UINT64: PyType = int 226 | VARIANT: PyType = object 227 | 228 | MAYBE_PLAYLIST: PyType = PlaylistEntry | None 229 | METADATA: PyType = DbusMetadata 230 | METADATA_ARRAY: PyType = Sequence[DbusMetadata] 231 | PLAYLIST: PyType = PlaylistEntry 232 | PLAYLISTS: PyType = Sequence[PlaylistEntry] 233 | 234 | 235 | MprisTypes: Final = _MprisTypes() 236 | type Compatible = Union[*MprisTypes] 237 | 238 | 239 | class Artist(NamedTuple): 240 | name: str = DEFAULT_ARTIST_NAME 241 | 242 | 243 | class Album(NamedTuple): 244 | art_url: str | None = None 245 | artists: Sequence[Artist] = NO_ARTISTS 246 | name: str = DEFAULT_ALBUM_NAME 247 | 248 | 249 | class Track(NamedTuple): 250 | album: Album | None = None 251 | art_url: str | None = None 252 | artists: Sequence[Artist] = NO_ARTISTS 253 | comments: list[str] | None = None 254 | disc_number: int | None = None 255 | length: Duration = DEFAULT_TRACK_LENGTH 256 | name: str = DEFAULT_TRACK_NAME 257 | track_id: DbusObj = DEFAULT_TRACK_ID 258 | track_number: int | None = None 259 | type: Enum | None = None 260 | uri: str | None = None 261 | 262 | 263 | def emit_properties_changed[I: MprisInterface]( 264 | interface: I, 265 | changed_properties: PropertyValues, 266 | invalidated_properties: Properties = INVALIDATED_PROPERTIES, 267 | ): 268 | interface.PropertiesChanged( 269 | interface.INTERFACE, 270 | changed_properties, 271 | invalidated_properties, 272 | ) 273 | 274 | 275 | def get_changed_properties[I: MprisInterface](interface: I, changes: Changes) -> PropertyValues: 276 | return { 277 | prop: getattr(interface, prop) 278 | for prop in changes 279 | } 280 | 281 | 282 | def dbus_emit_changes[I: MprisInterface](interface: I, changes: Changes): 283 | if not all(change in Property for change in changes): 284 | raise ValueError(f"Invalid property in {changes=}") 285 | 286 | changed_properties = get_changed_properties(interface, changes) 287 | emit_properties_changed(interface, changed_properties) 288 | -------------------------------------------------------------------------------- /src/mpris_server/assets/apache-2.0.txt: -------------------------------------------------------------------------------- 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. 203 | -------------------------------------------------------------------------------- /src/mpris_server/interfaces/player.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from fractions import Fraction 5 | from typing import ClassVar, Final 6 | 7 | from pydbus.generic import signal 8 | 9 | from .interface import MprisInterface, log_trace 10 | from ..base import BEGINNING, DbusObj, DbusTypes, Interface, MAX_RATE, MAX_VOLUME, MIN_RATE, MUTE_VOLUME, \ 11 | PAUSE_RATE, PlayState, Position, Rate, Track, Volume 12 | from ..enums import Access, Arg, Direction, LoopStatus, Method, Property, Signal 13 | from ..mpris.metadata import Metadata, MetadataEntries, create_metadata_from_track, get_dbus_metadata, update_metadata 14 | 15 | 16 | log = logging.getLogger(__name__) 17 | 18 | ERR_NOT_ENOUGH_METADATA: Final[str] = \ 19 | "Couldn't find enough metadata, please implement metadata() or get_stream_title() and get_current_track() methods.`" 20 | 21 | 22 | class Player(MprisInterface): 23 | INTERFACE: ClassVar[Interface] = Interface.Player 24 | 25 | __doc__: Final[str] = f""" 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 | 66 | """ 67 | 68 | Seeked: Final[signal] = signal() 69 | 70 | def _get_metadata(self) -> Metadata | None: 71 | if metadata := self.adapter.metadata(): 72 | return get_dbus_metadata(metadata) 73 | 74 | return None 75 | 76 | def _get_basic_metadata(self, track: Track) -> Metadata: 77 | metadata: Metadata = Metadata() 78 | 79 | if name := self.adapter.get_stream_title(): 80 | update_metadata(metadata, MetadataEntries.TITLE, name) 81 | 82 | if art_url := self._get_art_url(track): 83 | update_metadata(metadata, MetadataEntries.ART_URL, art_url) 84 | 85 | return metadata 86 | 87 | def _get_art_url(self, track: DbusObj | Track | None) -> str: 88 | return self.adapter.get_art_url(track) 89 | 90 | @property 91 | @log_trace 92 | def CanControl(self) -> bool: 93 | return self.adapter.can_control() 94 | 95 | @property 96 | @log_trace 97 | def CanGoNext(self) -> bool: 98 | # if not self.CanControl: 99 | # return False 100 | 101 | return self.adapter.can_go_next() 102 | 103 | @property 104 | @log_trace 105 | def CanGoPrevious(self) -> bool: 106 | # if not self.CanControl: 107 | # return False 108 | 109 | return self.adapter.can_go_previous() 110 | 111 | @property 112 | @log_trace 113 | def CanPause(self) -> bool: 114 | return self.adapter.can_pause() 115 | # if not self.CanControl: 116 | # return False 117 | 118 | # return True 119 | 120 | @property 121 | @log_trace 122 | def CanPlay(self) -> bool: 123 | # if not self.CanControl: 124 | # return False 125 | 126 | return self.adapter.can_play() 127 | 128 | @property 129 | @log_trace 130 | def CanSeek(self) -> bool: 131 | return self.adapter.can_seek() 132 | # if not self.CanControl: 133 | # return False 134 | 135 | # return True 136 | 137 | @property 138 | @log_trace 139 | def LoopStatus(self) -> LoopStatus: 140 | if not self.adapter.is_repeating(): 141 | return LoopStatus.NONE 142 | 143 | elif not self.adapter.is_playlist(): 144 | return LoopStatus.TRACK 145 | 146 | else: 147 | return LoopStatus.PLAYLIST 148 | 149 | @LoopStatus.setter 150 | @log_trace 151 | def LoopStatus(self, value: LoopStatus): 152 | if not self.CanControl: 153 | log.debug(f"Setting {self.INTERFACE}.{Property.LoopStatus} not allowed") 154 | return 155 | 156 | log.debug(f"Setting {self.INTERFACE}.{Property.LoopStatus} to {value}") 157 | 158 | self.adapter.set_loop_status(value) 159 | 160 | @property 161 | @log_trace 162 | def MinimumRate(self) -> Rate: 163 | if rate := self.adapter.get_minimum_rate(): 164 | return rate 165 | 166 | return MIN_RATE 167 | 168 | @property 169 | @log_trace 170 | def MaximumRate(self) -> Rate: 171 | if rate := self.adapter.get_maximum_rate(): 172 | return rate 173 | 174 | return MAX_RATE 175 | 176 | @property 177 | @log_trace 178 | def Metadata(self) -> Metadata: 179 | # prefer adapter's metadata to building our own 180 | if metadata := self._get_metadata(): 181 | return metadata 182 | 183 | # build metadata if no metadata supplied by adapter 184 | log.debug(f"Building {self.INTERFACE}.{Property.Metadata}") 185 | 186 | track = self.adapter.get_current_track() 187 | metadata: Metadata = self._get_basic_metadata(track) 188 | 189 | if not track: 190 | log.warning(ERR_NOT_ENOUGH_METADATA) 191 | return metadata 192 | 193 | return create_metadata_from_track(track, metadata) 194 | 195 | @property 196 | @log_trace 197 | def PlaybackStatus(self) -> PlayState: 198 | state = self.adapter.get_playstate() 199 | return state.value.title() 200 | 201 | @property 202 | @log_trace 203 | def Position(self) -> Position: 204 | return self.adapter.get_current_position() 205 | 206 | @property 207 | @log_trace 208 | def Rate(self) -> Rate: 209 | return self.adapter.get_rate() 210 | 211 | @Rate.setter 212 | @log_trace 213 | def Rate(self, value: Rate): 214 | if not self.CanControl: 215 | log.debug(f"Setting {self.INTERFACE}.Rate not allowed") 216 | return 217 | 218 | self.adapter.set_rate(value) 219 | 220 | if value == PAUSE_RATE: 221 | self.Pause() 222 | 223 | @property 224 | @log_trace 225 | def Shuffle(self) -> bool: 226 | return self.adapter.get_shuffle() 227 | 228 | @Shuffle.setter 229 | @log_trace 230 | def Shuffle(self, value: bool): 231 | if not self.CanControl: 232 | log.debug(f"Setting {self.INTERFACE}.{Property.Shuffle} not allowed") 233 | return 234 | 235 | log.debug(f"Setting {self.INTERFACE}.{Property.Shuffle} to {value}") 236 | self.adapter.set_shuffle(value) 237 | 238 | @property 239 | @log_trace 240 | def Volume(self) -> Volume: 241 | if self.adapter.is_mute(): 242 | return MUTE_VOLUME 243 | 244 | match volume := self.adapter.get_volume(): 245 | case Volume(): 246 | return volume 247 | 248 | case _ if not volume: 249 | return MUTE_VOLUME 250 | 251 | return volume 252 | 253 | @Volume.setter 254 | @log_trace 255 | def Volume(self, value: Volume | None): 256 | if not self.CanControl: 257 | log.debug(f"Setting {self.INTERFACE}.{Property.Volume} not allowed") 258 | return 259 | 260 | match volume := value: 261 | case None: 262 | return 263 | 264 | case Volume() | int() | float() | Fraction(): 265 | volume = Volume(volume) 266 | 267 | case unknown: 268 | log.warning(f"Unknown volume type: {type(unknown)}, continuing anyway.") 269 | 270 | if volume < MUTE_VOLUME: 271 | volume = MUTE_VOLUME 272 | 273 | elif volume > MAX_VOLUME: 274 | volume = MAX_VOLUME 275 | 276 | self.adapter.set_volume(volume) 277 | 278 | if volume > MUTE_VOLUME: 279 | self.adapter.set_mute(False) 280 | 281 | elif volume <= MUTE_VOLUME: 282 | self.adapter.set_mute(True) 283 | 284 | @log_trace 285 | def Next(self): 286 | if not self.CanGoNext: 287 | log.debug(f"{self.INTERFACE}.{Method.Next} not allowed") 288 | return 289 | 290 | self.adapter.next() 291 | 292 | @log_trace 293 | def OpenUri(self, uri: str): 294 | if not self.CanControl: 295 | log.debug(f"{self.INTERFACE}.{Method.OpenUri} not allowed") 296 | return 297 | 298 | # NOTE Check if URI has MIME type known to the backend, if MIME support 299 | # is added to the backend. 300 | self.adapter.open_uri(uri) 301 | 302 | @log_trace 303 | def Previous(self): 304 | if not self.CanGoPrevious: 305 | log.debug(f"{self.INTERFACE}.{Method.Previous} not allowed") 306 | return 307 | 308 | self.adapter.previous() 309 | 310 | @log_trace 311 | def Pause(self): 312 | if not self.CanPause: 313 | log.debug(f"{self.INTERFACE}.{Method.Pause} not allowed") 314 | return 315 | 316 | self.adapter.pause() 317 | 318 | @log_trace 319 | def Play(self): 320 | if not self.CanPlay: 321 | log.debug(f"{self.INTERFACE}.{Method.Play} not allowed") 322 | return 323 | 324 | match self.adapter.get_playstate(): 325 | case PlayState.PAUSED: 326 | self.adapter.resume() 327 | 328 | case _: 329 | self.adapter.play() 330 | 331 | @log_trace 332 | def PlayPause(self): 333 | if not self.CanPause: 334 | log.debug(f"{self.INTERFACE}.{Method.PlayPause} not allowed") 335 | return 336 | 337 | match self.adapter.get_playstate(): 338 | case PlayState.PLAYING: 339 | self.adapter.pause() 340 | 341 | case PlayState.PAUSED: 342 | self.adapter.resume() 343 | 344 | case PlayState.STOPPED: 345 | self.adapter.play() 346 | 347 | @log_trace 348 | def Seek(self, offset: Position): 349 | if not self.CanSeek: 350 | log.debug(f"{self.INTERFACE}.{Method.Seek} not allowed") 351 | return 352 | 353 | current_position = self.adapter.get_current_position() 354 | new_position = current_position + offset 355 | 356 | if new_position < BEGINNING: 357 | new_position = BEGINNING 358 | 359 | self.adapter.seek(new_position) 360 | 361 | @log_trace 362 | def SetPosition(self, track_id: str, position: Position): 363 | if not self.CanSeek: 364 | log.debug(f"{self.INTERFACE}.{Method.SetPosition} not allowed") 365 | return 366 | 367 | self.adapter.seek(position, track_id=track_id) 368 | 369 | # metadata = self.adapter.metadata() 370 | # current_track: Optional[Track] = None 371 | 372 | ##use metadata from adapter if available 373 | # if metadata \ 374 | # and 'mpris:trackid' in metadata \ 375 | # and 'mpris:length' in metadata: 376 | # current_track = Track( 377 | # track_id=metadata['mpris:trackid'], 378 | # length=metadata['mpris:length'] 379 | # ) 380 | 381 | ##if no metadata, build metadata from Track interface 382 | # else: 383 | # current_track = self.adapter.get_current_track() 384 | 385 | # if current_track is None: 386 | # return 387 | 388 | # if track_id != current_track.track_id: 389 | # return 390 | 391 | # if position < BEGINNING: 392 | # return 393 | 394 | # if current_track.length < position: 395 | # return 396 | 397 | # self.adapter.seek(position, track_id=track_id) 398 | 399 | @log_trace 400 | def Stop(self): 401 | if not self.CanControl: 402 | log.debug(f"{self.INTERFACE}.{Method.Stop} not allowed") 403 | return 404 | 405 | self.adapter.stop() 406 | -------------------------------------------------------------------------------- /src/mpris_server/mpris/metadata.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from collections.abc import Collection, Iterable, Mapping, Sequence 5 | from typing import Any, Final, NamedTuple, Required, Self, TypedDict, cast 6 | 7 | from gi.repository.GLib import Variant 8 | from strenum import StrEnum 9 | 10 | from ..base import Artist, Compatible, DEFAULT_TRACK_ID, DbusPyTypes, DbusTypes, MprisTypes, NO_ARTIST_NAME, PyType, \ 11 | Track 12 | from ..types import get_type, is_type 13 | 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | FIRST: Final[int] = 0 18 | FIELDS_ERROR: Final[str] = "Added or missing fields." 19 | 20 | 21 | type Name = str 22 | type MetadataEntry = str 23 | type NameMetadata = tuple[Name, DbusPyTypes] 24 | type SortedMetadata = dict[Name, DbusPyTypes] 25 | 26 | 27 | class MetadataEntries(StrEnum): 28 | ALBUM: MetadataEntry = "xesam:album" 29 | ALBUM_ARTISTS: MetadataEntry = "xesam:albumArtist" 30 | ART_URL: MetadataEntry = "mpris:artUrl" 31 | ARTISTS: MetadataEntry = "xesam:artist" 32 | AS_TEXT: MetadataEntry = 'xesam:asText' 33 | AUDIO_BPM: MetadataEntry = 'xesam:audioBPM' 34 | AUTO_RATING: MetadataEntry = 'xesam:autoRating' 35 | COMMENT: MetadataEntry = "xesam:comment" 36 | COMPOSER: MetadataEntry = "xesam:composer" 37 | CONTENT_CREATED: MetadataEntry = "xesam:contentCreated" 38 | DISC_NUMBER: MetadataEntry = "xesam:discNumber" 39 | FIRST_USED: MetadataEntry = "xesam:firstUsed" 40 | GENRE: MetadataEntry = "xesam:genre" 41 | LAST_USED: MetadataEntry = "xesam:lastUsed" 42 | LENGTH: MetadataEntry = "mpris:length" 43 | LYRICIST: MetadataEntry = "xesam:lyricist" 44 | TITLE: MetadataEntry = "xesam:title" 45 | TRACK_ID: MetadataEntry = "mpris:trackid" 46 | TRACK_NUMBER: MetadataEntry = "xesam:trackNumber" 47 | URL: MetadataEntry = "xesam:url" 48 | USE_COUNT: MetadataEntry = "xesam:useCount" 49 | USER_RATING: MetadataEntry = "xesam:userRating" 50 | 51 | @classmethod 52 | def sorted(cls: type[Self]) -> list[Self]: 53 | return sorted(cls, key=sort_enum_by_name) 54 | 55 | @classmethod 56 | def to_dict(cls: type[Self]) -> dict[str, Self]: 57 | return { 58 | enum.name: enum.value 59 | for enum in cls.sorted() 60 | } 61 | 62 | 63 | # map of D-Bus metadata entries and their D-Bus types 64 | METADATA_TYPES: Final[dict[MetadataEntries, DbusTypes]] = { 65 | MetadataEntries.ALBUM: DbusTypes.STRING, 66 | MetadataEntries.ALBUM_ARTISTS: DbusTypes.STRING_ARRAY, 67 | MetadataEntries.ART_URL: DbusTypes.STRING, 68 | MetadataEntries.ARTISTS: DbusTypes.STRING_ARRAY, 69 | MetadataEntries.AS_TEXT: DbusTypes.STRING_ARRAY, 70 | MetadataEntries.AUDIO_BPM: DbusTypes.INT32, 71 | MetadataEntries.AUTO_RATING: DbusTypes.DOUBLE, 72 | MetadataEntries.COMMENT: DbusTypes.STRING_ARRAY, 73 | MetadataEntries.COMPOSER: DbusTypes.STRING_ARRAY, 74 | MetadataEntries.CONTENT_CREATED: DbusTypes.STRING, 75 | MetadataEntries.DISC_NUMBER: DbusTypes.INT32, 76 | MetadataEntries.FIRST_USED: DbusTypes.STRING, 77 | MetadataEntries.GENRE: DbusTypes.STRING_ARRAY, 78 | MetadataEntries.LAST_USED: DbusTypes.STRING_ARRAY, 79 | MetadataEntries.LENGTH: DbusTypes.INT64, 80 | MetadataEntries.LYRICIST: DbusTypes.STRING_ARRAY, 81 | MetadataEntries.TITLE: DbusTypes.STRING, 82 | MetadataEntries.TRACK_ID: DbusTypes.STRING, 83 | MetadataEntries.TRACK_NUMBER: DbusTypes.INT32, 84 | MetadataEntries.URL: DbusTypes.STRING, 85 | MetadataEntries.USE_COUNT: DbusTypes.INT32, 86 | MetadataEntries.USER_RATING: DbusTypes.DOUBLE, 87 | } 88 | 89 | DBUS_TYPES_TO_PY_TYPES: Final[dict[DbusTypes, PyType]] = { 90 | DbusTypes.BOOLEAN: MprisTypes.BOOLEAN, 91 | DbusTypes.DATETIME: MprisTypes.DATETIME, 92 | DbusTypes.DOUBLE: MprisTypes.DOUBLE, 93 | DbusTypes.INT32: MprisTypes.INT32, 94 | DbusTypes.INT64: MprisTypes.INT64, 95 | DbusTypes.OBJ: MprisTypes.OBJ, 96 | DbusTypes.OBJ_ARRAY: MprisTypes.OBJ_ARRAY, 97 | DbusTypes.STRING: MprisTypes.STRING, 98 | DbusTypes.STRING_ARRAY: MprisTypes.STRING_ARRAY, 99 | DbusTypes.UINT32: MprisTypes.UINT32, 100 | DbusTypes.UINT64: MprisTypes.UINT64, 101 | } 102 | 103 | METADATA_TO_PY_TYPES: Final[dict[MetadataEntries, PyType]] = { 104 | MetadataEntries.ALBUM: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING], 105 | MetadataEntries.ALBUM_ARTISTS: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY], 106 | MetadataEntries.ART_URL: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING], 107 | MetadataEntries.ARTISTS: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY], 108 | MetadataEntries.AS_TEXT: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY], 109 | MetadataEntries.AUDIO_BPM: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY], 110 | MetadataEntries.AUTO_RATING: DBUS_TYPES_TO_PY_TYPES[DbusTypes.DOUBLE], 111 | MetadataEntries.COMMENT: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY], 112 | MetadataEntries.COMPOSER: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY], 113 | MetadataEntries.CONTENT_CREATED: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING], 114 | MetadataEntries.DISC_NUMBER: DBUS_TYPES_TO_PY_TYPES[DbusTypes.INT32], 115 | MetadataEntries.FIRST_USED: DBUS_TYPES_TO_PY_TYPES[DbusTypes.DATETIME], 116 | MetadataEntries.GENRE: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY], 117 | MetadataEntries.LAST_USED: DBUS_TYPES_TO_PY_TYPES[DbusTypes.DATETIME], 118 | MetadataEntries.LENGTH: DBUS_TYPES_TO_PY_TYPES[DbusTypes.INT64], 119 | MetadataEntries.LYRICIST: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY], 120 | MetadataEntries.TITLE: Required[DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING]], 121 | MetadataEntries.TRACK_ID: DBUS_TYPES_TO_PY_TYPES[DbusTypes.OBJ], 122 | MetadataEntries.TRACK_NUMBER: DBUS_TYPES_TO_PY_TYPES[DbusTypes.INT32], 123 | MetadataEntries.URL: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING], 124 | MetadataEntries.USE_COUNT: DBUS_TYPES_TO_PY_TYPES[DbusTypes.INT32], 125 | MetadataEntries.USER_RATING: DBUS_TYPES_TO_PY_TYPES[DbusTypes.DOUBLE], 126 | } 127 | 128 | 129 | Metadata = TypedDict('Metadata', METADATA_TO_PY_TYPES, total=False) 130 | 131 | DEFAULT_METADATA: Final[Metadata] = Metadata() 132 | 133 | assert ( 134 | len(MetadataEntries) 135 | == len(METADATA_TYPES) 136 | == len(METADATA_TO_PY_TYPES) 137 | == len(Metadata.__annotations__) 138 | ), FIELDS_ERROR 139 | 140 | 141 | class _MetadataTypes(NamedTuple): 142 | ALBUM: PyType = METADATA_TO_PY_TYPES[MetadataEntries.ALBUM] 143 | ALBUM_ARTISTS: PyType = METADATA_TO_PY_TYPES[MetadataEntries.ALBUM_ARTISTS] 144 | ART_URL: PyType = METADATA_TO_PY_TYPES[MetadataEntries.ART_URL] 145 | ARTISTS: PyType = METADATA_TO_PY_TYPES[MetadataEntries.ARTISTS] 146 | AS_TEXT: PyType = METADATA_TO_PY_TYPES[MetadataEntries.AS_TEXT] 147 | AUDIO_BPM: PyType = METADATA_TO_PY_TYPES[MetadataEntries.AUDIO_BPM] 148 | AUTO_RATING: PyType = METADATA_TO_PY_TYPES[MetadataEntries.AUTO_RATING] 149 | COMMENT: PyType = METADATA_TO_PY_TYPES[MetadataEntries.COMMENT] 150 | COMPOSER: PyType = METADATA_TO_PY_TYPES[MetadataEntries.COMPOSER] 151 | CONTENT_CREATED: PyType = METADATA_TO_PY_TYPES[MetadataEntries.CONTENT_CREATED] 152 | DISC_NUMBER: PyType = METADATA_TO_PY_TYPES[MetadataEntries.DISC_NUMBER] 153 | FIRST_USED: PyType = METADATA_TO_PY_TYPES[MetadataEntries.FIRST_USED] 154 | GENRE: PyType = METADATA_TO_PY_TYPES[MetadataEntries.GENRE] 155 | LAST_USED: PyType = METADATA_TO_PY_TYPES[MetadataEntries.FIRST_USED] 156 | LENGTH: PyType = METADATA_TO_PY_TYPES[MetadataEntries.LENGTH] 157 | LYRICIST: PyType = METADATA_TO_PY_TYPES[MetadataEntries.LYRICIST] 158 | TITLE: PyType = METADATA_TO_PY_TYPES[MetadataEntries.TITLE] 159 | TRACK_ID: PyType = METADATA_TO_PY_TYPES[MetadataEntries.TRACK_ID] 160 | TRACK_NUMBER: PyType = METADATA_TO_PY_TYPES[MetadataEntries.TRACK_NUMBER] 161 | URL: PyType = METADATA_TO_PY_TYPES[MetadataEntries.URL] 162 | USE_COUNT: PyType = METADATA_TO_PY_TYPES[MetadataEntries.USE_COUNT] 163 | USER_RATING: PyType = METADATA_TO_PY_TYPES[MetadataEntries.USER_RATING] 164 | 165 | 166 | MetadataTypes: Final[_MetadataTypes] = _MetadataTypes() 167 | 168 | assert len(MetadataEntries) == len(MetadataTypes), FIELDS_ERROR 169 | 170 | 171 | class MetadataObj(NamedTuple): 172 | album: MetadataTypes.ALBUM | None = None 173 | album_artists: MetadataTypes.ALBUM_ARTISTS | None = None 174 | art_url: MetadataTypes.ART_URL | None = None 175 | artists: MetadataTypes.ARTISTS | None = None 176 | as_text: MetadataTypes.AS_TEXT | None = None 177 | audio_bpm: MetadataTypes.AUDIO_BPM | None = None 178 | auto_rating: MetadataTypes.AUTO_RATING | None = None 179 | comments: MetadataTypes.COMMENT | None = None 180 | composer: MetadataTypes.COMPOSER | None = None 181 | content_created: MetadataTypes.CONTENT_CREATED | None = None 182 | disc_number: MetadataTypes.DISC_NUMBER | None = None 183 | first_used: MetadataTypes.FIRST_USED | None = None 184 | genre: MetadataTypes.GENRE | None = None 185 | last_used: MetadataTypes.LAST_USED | None = None 186 | length: MetadataTypes.LENGTH | None = None 187 | lyricist: MetadataTypes.LYRICIST | None = None 188 | title: MetadataTypes.TITLE | None = None 189 | track_id: MetadataTypes.TRACK_ID = DEFAULT_TRACK_ID 190 | track_number: MetadataTypes.TRACK_NUMBER | None = None 191 | url: MetadataTypes.URL | None = None 192 | use_count: MetadataTypes.USE_COUNT | None = None 193 | user_rating: MetadataTypes.USER_RATING | None = None 194 | 195 | def sorted(self) -> SortedMetadata: 196 | items: Iterable[NameMetadata] = self._asdict().items() 197 | items = sorted(items, key=sort_metadata_by_name) 198 | 199 | return dict(items) 200 | 201 | def to_dict(self) -> Metadata: 202 | entries = MetadataEntries.sorted() 203 | vals = self.sorted().values() 204 | pairs = zip(entries, vals) 205 | # return dict(filter(None, pairs)) 206 | 207 | return { 208 | entry: metadata 209 | for entry, metadata in pairs 210 | if metadata is not None 211 | } 212 | 213 | 214 | assert len(MetadataEntries) == len(MetadataObj._fields), FIELDS_ERROR 215 | 216 | 217 | type ValidMetadata = Metadata | MetadataObj 218 | type RuntimeTypes = tuple[type, ...] 219 | 220 | 221 | def get_runtime_types() -> RuntimeTypes: 222 | types: set[type] = { 223 | get_type(val) 224 | for val in DBUS_TYPES_TO_PY_TYPES.values() 225 | if is_type(val) 226 | } 227 | 228 | return tuple(types) 229 | 230 | 231 | DBUS_RUNTIME_TYPES: Final[RuntimeTypes] = get_runtime_types() 232 | 233 | 234 | def is_null_collection(val: Any) -> bool: 235 | if isinstance(val, Collection): 236 | return all(item is None for item in val) 237 | 238 | return False 239 | 240 | 241 | def is_dbus_type(val: Any) -> bool: 242 | return isinstance(val, DBUS_RUNTIME_TYPES) 243 | 244 | 245 | def is_valid_metadata(entry: str, val: Any) -> bool: 246 | if val is None or entry not in METADATA_TYPES: 247 | log.debug(f"<{entry=}, {val=}> isn't valid metadata, skipping.") 248 | return False 249 | 250 | return is_dbus_type(val) and not is_null_collection(val) 251 | 252 | 253 | def get_dbus_var(entry: MetadataEntries, val: Any) -> Variant: 254 | metadata_type: DbusTypes = METADATA_TYPES[entry] 255 | var: Compatible = to_compatible_type(val) 256 | 257 | log.debug(f"Translating <{entry=}, {val=}> to <{metadata_type=}, {var=}>") 258 | 259 | return Variant(metadata_type, var) 260 | 261 | 262 | def to_compatible_type(val: Any) -> Compatible: 263 | match val: 264 | case bytes(string): 265 | return string.decode() 266 | 267 | case str(string): 268 | return string 269 | 270 | case Mapping() as mapping: 271 | return dict(mapping) 272 | 273 | case Sequence() as sequence: 274 | return list(sequence) 275 | 276 | return val 277 | 278 | 279 | def get_dbus_metadata(metadata: ValidMetadata) -> Metadata: 280 | if isinstance(metadata, MetadataObj): 281 | metadata: Metadata = metadata.to_dict() 282 | 283 | metadata = cast(Metadata, metadata) 284 | 285 | return { 286 | entry: get_dbus_var(entry, value) 287 | for entry, value in metadata.items() 288 | if is_valid_metadata(entry, value) 289 | } 290 | 291 | 292 | def sort_metadata_by_name(name_metadata: NameMetadata) -> Name: 293 | name, _ = name_metadata 294 | 295 | return name.casefold() 296 | 297 | 298 | def sort_enum_by_name(enum: StrEnum) -> str: 299 | return enum.name.casefold() 300 | 301 | 302 | def sort_artists_by_name(artist: Artist) -> str: 303 | return artist.name.casefold() or NO_ARTIST_NAME 304 | 305 | 306 | def get_names(artists: list[Artist]) -> list[str]: 307 | artists = sorted(artists, key=sort_artists_by_name) 308 | 309 | return [artist.name for artist in artists if artist.name] 310 | 311 | 312 | def update_metadata(metadata: Metadata, entry: MetadataEntries, value: Any) -> Metadata: 313 | if value is None: 314 | return metadata 315 | 316 | metadata[entry] = get_dbus_var(entry, value) 317 | 318 | return metadata 319 | 320 | 321 | def update_metadata_from_track(track: Track, metadata: Metadata | None = None) -> Metadata: 322 | if metadata is None: 323 | metadata = Metadata() 324 | 325 | album, art_url, artists, disc_number, length, name, track_id, track_number, _, uri = track 326 | 327 | if name and (entry := MetadataEntries.TITLE) not in metadata: 328 | update_metadata(metadata, entry, name) 329 | 330 | if art_url and (entry := MetadataEntries.ART_URL) not in metadata: 331 | update_metadata(metadata, entry, art_url) 332 | 333 | if length: 334 | update_metadata(metadata, MetadataEntries.LENGTH, length) 335 | 336 | if uri: 337 | update_metadata(metadata, MetadataEntries.URL, uri) 338 | 339 | if artists: 340 | names = get_names(artists) 341 | update_metadata(metadata, MetadataEntries.ARTISTS, names) 342 | 343 | if album and (artists := album.artists): 344 | names = get_names(artists) 345 | update_metadata(metadata, MetadataEntries.ALBUM_ARTISTS, names) 346 | 347 | if album and (name := album.name): 348 | update_metadata(metadata, MetadataEntries.ALBUM, name) 349 | 350 | if disc_number: 351 | update_metadata(metadata, MetadataEntries.DISC_NUMBER, disc_number) 352 | 353 | if track_id: 354 | update_metadata(metadata, MetadataEntries.TRACK_ID, track_id) 355 | 356 | if track_number: 357 | update_metadata(metadata, MetadataEntries.TRACK_NUMBER, track_number) 358 | 359 | return metadata 360 | 361 | 362 | def create_metadata_from_track(track: Track | None, metadata: Metadata | None = None) -> Metadata: 363 | match metadata: 364 | case dict(): 365 | metadata: Metadata = metadata.copy() 366 | 367 | case None | _: 368 | metadata: Metadata = Metadata() 369 | 370 | if not track: 371 | return metadata 372 | 373 | update_metadata_from_track(track, metadata) 374 | 375 | return metadata 376 | --------------------------------------------------------------------------------