├── 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 |
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 |
--------------------------------------------------------------------------------