pax_global_header 0000666 0000000 0000000 00000000064 14533464522 0014522 g ustar 00root root 0000000 0000000 52 comment=129118d9fc13b181e16d809738af74a8046fc62c
mpris_server-0.9.0/ 0000775 0000000 0000000 00000000000 14533464522 0014250 5 ustar 00root root 0000000 0000000 mpris_server-0.9.0/.github/ 0000775 0000000 0000000 00000000000 14533464522 0015610 5 ustar 00root root 0000000 0000000 mpris_server-0.9.0/.github/FUNDING.yml 0000664 0000000 0000000 00000000107 14533464522 0017423 0 ustar 00root root 0000000 0000000 # These are supported funding model platforms
github: [alexdelorenzo]
mpris_server-0.9.0/LICENSE 0000664 0000000 0000000 00000016743 14533464522 0015270 0 ustar 00root root 0000000 0000000 GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library. mpris_server-0.9.0/MANIFEST.in 0000664 0000000 0000000 00000000053 14533464522 0016004 0 ustar 00root root 0000000 0000000 include README.md requirements.txt LICENSE
mpris_server-0.9.0/NOTICE 0000664 0000000 0000000 00000000610 14533464522 0015151 0 ustar 00root root 0000000 0000000 All changes are released under the AGPLv3, with Alex DeLorenzo as the copyright holder.
You can message me for alternative licensing.
Mopidy-MPRIS is licensed under the Apache 2.0 license, which can be read in full in assets/apache-2.0.txt.
Stein Magnus Jodal is the author of Mopidy-MPRIS, and other contributors can be found at https://github.com/mopidy/mopidy-mpris/graphs/contributors.
mpris_server-0.9.0/README.md 0000664 0000000 0000000 00000012101 14533464522 0015522 0 ustar 00root root 0000000 0000000 # ▶️ Add MPRIS integration to media players
`mpris_server` provides adapters to integrate [MPRIS](https://specifications.freedesktop.org/mpris-spec/2.2/) support in
your media player or device. By supporting MPRIS in your app, you will allow Linux users to control all aspects of
playback from the media controllers they already have installed.
Whereas [existing MPRIS libraries for Python](https://github.com/hugosenari/mpris2) implement clients for apps with
existing MPRIS support, `mpris_server` is a library used to implement MPRIS support in apps that don't already have it.
If you want to give your media player an MPRIS interface, then `mpris_server` is right for you.
`mpris_server` is a fork of [Mopidy-MPRIS](https://github.com/mopidy/mopidy-mpris) that was extended and made into a
general purpose library.
Check out [`📺 cast_control`](https://github.com/alexdelorenzo/cast_control) for an app that uses `mpris_server`.
## Features
Implements the following from the [MPRIS specification](https://specifications.freedesktop.org/mpris-spec/2.2/):
* [x] MediaPlayer2
* [x] MediaPlayer2.Player
* [x] MediaPlayer2.Playlist
* [x] MediaPlayer2.TrackList
The library also provides an event handler that emits `org.freedesktop.DBus.Properties.PropertiesChanged` in response to
changes in your media player. This allows for real-time updates from your media player to D-Bus.
## Installation
### Requirements
- Linux / *BSD / [macOS](https://github.com/zbentley/dbus-osx-examples)
- [D-Bus](https://www.freedesktop.org/wiki/Software/dbus/)
- Python >= 3.7
- [PyGObject](https://pypi.org/project/PyGObject/)
- `requirements.txt`
#### Installing PyGObject
On Debian-derived distributions like Ubuntu, install `python3-gi` with `apt`. On Arch, you'll want to
install `python-gobject`.
On macOS, install [`pygobject3`](https://formulae.brew.sh/formula/pygobject3) via `brew`. Note that `mpris_server` on
macOS hasn't been tested, but is theoretically possible to use.
Use `pip` to install `PyGObject>=3.34.0` if there are no installation candidates available in your vendor's package
repositories.
### PyPI
`pip3 install mpris_server`
### GitHub
Clone the repo, run `pip3 install -r requirements.txt`, followed by `python3 setup.py install`.
## Usage
### Implement `adapters.MprisAdapter`
Subclass `adapters.MprisAdapter` and implement each method.
After subclassing, pass an instance to an instance of `server.Server`.
### Implement `events.EventAdapter`
Subclass `adapters.EventAdapter`. This interface has a good default implementation, only override its methods if your
app calls for it.
If you choose to re-implement its methods, call `emit_changes()` with the corresponding interface and a `List[str]`
of [properties](https://specifications.freedesktop.org/mpris-spec/2.2/Player_Interface.html) that changed.
Integrate the adapter with your application to emit changes in your media player that DBus needs to know about. For
example, if the user pauses the media player, be sure to call `EventAdapter.on_playpause()` in the app. DBus won't know
about the change otherwise.
### Create the Server and Publish
Create an instance of `server.Server`, pass it an instance of your `MprisAdapter`, and call `publish()` to publish your
media player via DBus.
```python3
mpris = Server('MyMediaPlayer', adapter=my_adapter)
mpris.publish()
```
Call `loop()` to enter the DBus event loop, or enter the DBus event loop elsewhere in your code.
```python3
mpris.loop()
```
Or:
```python3
from gi.repository import GLib
loop = GLib.MainLoop()
loop.run()
```
### Example
```python3
from mpris_server.adapters import MprisAdapter
from mpris_server.events import EventAdapter
from mpris_server.server import Server
from mpris_server import Metadata
from my_app import app # custom app you want to integrate
class MyAppAdapter(MprisAdapter):
# Make sure to implement all methods on MprisAdapter, not just metadata()
def metadata(self) -> Metadata:
...
# and so on
class MyAppEventHandler(EventAdapter):
# EventAdapter has good default implementations for its methods.
# Only override the default methods if it suits your app.
def on_app_event(self, event: str):
# trigger DBus updates based on events in your app
if event == 'pause':
self.on_playpause()
...
# and so on
# create mpris adapter and initialize mpris server
my_adapter = MyAppAdapter()
mpris = Server('MyApp', adapter=my_adapter)
# initialize app integration with mpris
event_handler = MyAppEventHandler(root=mpris.root, player=mpris.player)
app.register_event_handler(event_handler)
# publish and serve
mpris.loop()
```
## Support
Want to support this project and [other open-source projects](https://github.com/alexdelorenzo) like it?
## License
`mpris_server` is released under the AGPLv3, see [`LICENSE`](/LICENSE). Message me if you'd like to use this project
with a different license.
mpris_server-0.9.0/assets/ 0000775 0000000 0000000 00000000000 14533464522 0015552 5 ustar 00root root 0000000 0000000 mpris_server-0.9.0/assets/apache-2.0.txt 0000664 0000000 0000000 00000026136 14533464522 0020041 0 ustar 00root root 0000000 0000000
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
mpris_server-0.9.0/mpris_server/ 0000775 0000000 0000000 00000000000 14533464522 0016770 5 ustar 00root root 0000000 0000000 mpris_server-0.9.0/mpris_server/__init__.py 0000664 0000000 0000000 00000000476 14533464522 0021110 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from typing import Final
from . import adapters, base, interfaces, mpris, server, types
from .adapters import *
from .base import *
from .enums import *
from .events import *
from .interfaces import *
from .mpris import *
from .server import *
__version__: Final[str] = '0.9.0'
mpris_server-0.9.0/mpris_server/adapters.py 0000664 0000000 0000000 00000011221 14533464522 0021142 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from abc import ABC
from typing import Final
from .base import ActivePlaylist, DEFAULT_DESKTOP, DEFAULT_ORDERINGS, DEFAULT_PLAYLIST_COUNT, DEFAULT_RATE, DbusObj, \
MIME_TYPES, NoTrack, Ordering, Paths, PlayState, PlaylistEntry, Position, Rate, Track, URI, Volume
from .enums import LoopStatus
from .mpris.metadata import Metadata, ValidMetadata
__all__ = [
'MprisAdapter',
'NoTrack',
'PlayerAdapter',
'PlaylistAdapter',
'RootAdapter',
'TrackListAdapter',
]
DEFAULT_ADAPTER_NAME: Final[str] = 'MprisAdapter'
DEFAULT_FULLSCREEN: Final[bool] = False
class RootAdapter(ABC):
def can_fullscreen(self) -> bool:
pass
def can_quit(self) -> bool:
pass
def can_raise(self) -> bool:
pass
def get_desktop_entry(self) -> Paths:
return DEFAULT_DESKTOP
def get_fullscreen(self) -> bool:
return DEFAULT_FULLSCREEN
def get_mime_types(self) -> list[str]:
return MIME_TYPES
def get_uri_schemes(self) -> list[str]:
return URI
def has_tracklist(self) -> bool:
pass
def quit(self):
pass
def set_fullscreen(self, value: bool):
pass
def set_raise(self, value: bool):
pass
class PlayerAdapter(ABC):
def metadata(self) -> ValidMetadata:
"""
Implement this function to supply your own MPRIS Metadata.
If this function is implemented, metadata won't be built from get_current_track().
See: https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata/
"""
pass
def get_current_track(self) -> Track:
"""
This function is an artifact of forking the base MPRIS library to a generic interface.
The base library expected Track-like objects to build metadata.
If metadata() is implemented, this function won't be used to build MPRIS metadata.
"""
pass
def can_control(self) -> bool:
pass
def can_go_next(self) -> bool:
pass
def can_go_previous(self) -> bool:
pass
def can_pause(self) -> bool:
pass
def can_play(self) -> bool:
pass
def can_seek(self) -> bool:
pass
def get_art_url(self, track: DbusObj | Track | None) -> str:
pass
def get_current_position(self) -> Position:
pass
def get_maximum_rate(self) -> Rate:
pass
def get_minimum_rate(self) -> Rate:
pass
def get_next_track(self) -> Track:
pass
def get_playstate(self) -> PlayState:
pass
def get_previous_track(self) -> Track:
pass
def get_rate(self) -> Rate:
return DEFAULT_RATE
def get_shuffle(self) -> bool:
pass
def get_stream_title(self) -> str:
pass
def get_volume(self) -> Volume:
pass
def is_mute(self) -> bool:
pass
def is_playlist(self) -> bool:
pass
def is_repeating(self) -> bool:
pass
def next(self):
pass
def open_uri(self, uri: str):
pass
def pause(self):
pass
def play(self):
pass
def previous(self):
pass
def resume(self):
pass
def seek(self, time: Position, track_id: DbusObj | None = None):
pass
def set_loop_status(self, value: LoopStatus):
match value:
case LoopStatus.NONE:
self.set_repeating(False)
case LoopStatus.TRACK | LoopStatus.PLAYLIST:
self.set_repeating(True)
def set_maximum_rate(self, value: Rate):
pass
def set_minimum_rate(self, value: Rate):
pass
def set_mute(self, value: bool):
pass
def set_rate(self, value: Rate):
pass
def set_repeating(self, value: bool):
pass
def set_shuffle(self, value: bool):
pass
def set_volume(self, value: Volume):
pass
def stop(self):
pass
class PlaylistAdapter(ABC):
def activate_playlist(self, id: DbusObj):
pass
def get_active_playlist(self) -> ActivePlaylist:
pass
def get_orderings(self) -> list[Ordering]:
return DEFAULT_ORDERINGS
def get_playlist_count(self) -> int:
return DEFAULT_PLAYLIST_COUNT
def get_playlists(self, index: int, max_count: int, order: str, reverse: bool) -> list[PlaylistEntry]:
pass
class TrackListAdapter(ABC):
def add_track(self, uri: str, after_track: DbusObj, set_as_current: bool):
pass
def can_edit_tracks(self) -> bool:
pass
def get_tracks(self) -> list[DbusObj]:
pass
def get_tracks_metadata(self, track_ids: list[DbusObj]) -> list[Metadata]:
pass
def go_to(self, track_id: DbusObj):
pass
def remove_track(self, track_id: DbusObj):
pass
class MprisAdapter(
RootAdapter,
PlayerAdapter,
PlaylistAdapter,
TrackListAdapter,
ABC
):
"""
MRPRIS interface for your application.
The MPRIS implementation is supplied with information
returned from this adapter.
"""
name: str
def __init__(self, name: str = DEFAULT_ADAPTER_NAME):
self.name = name
mpris_server-0.9.0/mpris_server/base.py 0000664 0000000 0000000 00000016103 14533464522 0020255 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from collections.abc import Callable, Collection, Iterable, Mapping, Sequence
from decimal import Decimal
from enum import Enum, auto
from os import PathLike
from string import ascii_letters, digits
from typing import Concatenate, Final, NamedTuple, Self, TYPE_CHECKING, Union
from gi.repository.GLib import Variant
from strenum import StrEnum
from .enums import Property
from .types import GenericAliases
if TYPE_CHECKING:
from .interfaces.interface import MprisInterface
INTERFACE: Final[str] = "org.mpris.MediaPlayer2"
DBUS_PATH: Final[str] = f"/{INTERFACE.replace('.', '/')}"
NAME: Final[str] = "mprisServer"
NoTrack: Final[DbusObj] = f'{DBUS_PATH}/TrackList/NoTrack'
type Properties = Collection[Property]
MIME_TYPES: Final[list[str]] = [
"audio/mpeg",
"application/ogg",
"video/mpeg",
]
URI: Final[list[str]] = [
"file",
]
DEFAULT_DESKTOP: Final[str] = ''
# typically, these are the props that D-Bus needs to be notified about
# upon specific state-change events.
ON_ENDED_PROPS: Final[Properties] = [
Property.PlaybackStatus,
]
ON_VOLUME_PROPS: Final[Properties] = [
Property.Metadata,
Property.Volume,
]
ON_PLAYBACK_PROPS: Final[Properties] = [
Property.CanControl,
Property.MaximumRate,
Property.Metadata,
Property.MinimumRate,
Property.PlaybackStatus,
Property.Rate,
]
ON_PLAYPAUSE_PROPS: Final[Properties] = [
Property.PlaybackStatus,
]
ON_TITLE_PROPS: Final[Properties] = [
Property.Metadata,
]
ON_OPTION_PROPS: Final[Properties] = [
Property.CanGoNext,
Property.CanGoPrevious,
Property.CanPause,
Property.CanPlay,
Property.LoopStatus,
Property.Shuffle,
]
ON_SEEK_PROPS: Final[Properties] = [
Property.CanSeek,
Property.Position,
]
# all props for each interface
ON_PLAYER_PROPS: Final[Properties] = sorted({
*ON_ENDED_PROPS,
*ON_OPTION_PROPS,
*ON_PLAYBACK_PROPS,
*ON_PLAYPAUSE_PROPS,
*ON_SEEK_PROPS,
*ON_TITLE_PROPS,
*ON_VOLUME_PROPS,
})
ON_TRACKS_PROPS: Final[Properties] = [
Property.CanEditTracks,
Property.Tracks,
]
ON_PLAYLIST_PROPS: Final[Properties] = [
Property.ActivePlaylist,
Property.Orderings,
Property.PlaylistCount,
]
ON_ROOT_PROPS: Final[Properties] = [
Property.CanQuit,
Property.CanRaise,
Property.CanSetFullscreen,
Property.DesktopEntry,
Property.Fullscreen,
Property.HasTrackList,
Property.Identity,
Property.SupportedMimeTypes,
Property.SupportedUriSchemes,
]
INVALIDATED_PROPERTIES: Final[Properties] = ()
class Ordering(StrEnum):
Alphabetical = auto()
User = auto()
DEFAULT_TRACK_ID: Final[str] = '/default/1'
DEFAULT_TRACK_NAME: Final[str] = "Default Track"
DEFAULT_TRACK_LENGTH: Final[int] = 0
DEFAULT_PLAYLIST_COUNT: Final[int] = 1
DEFAULT_ORDERINGS: Final[list[Ordering]] = [
Ordering.Alphabetical,
Ordering.User,
]
DEFAULT_ALBUM_NAME: Final[str] = "Default Album"
DEFAULT_ARTIST_NAME: Final[str] = "Default Artist"
NO_ARTISTS: Final[tuple[Artist, ...]] = tuple()
NO_ARTIST_NAME: Final[str] = ''
BEGINNING: Final[int] = 0
# valid characters for a DBus name
VALID_PUNC: Final[str] = '_'
VALID_CHARS: Final[set[str]] = {*ascii_letters, *digits, *VALID_PUNC}
NAME_PREFIX: Final[str] = "MprisServer_"
RAND_CHARS: Final[int] = 5
# type aliases
type Paths = PathLike | str
type Changes = Iterable[Property | str]
# units and convenience aliases
Microseconds = int
Position = Microseconds
Duration = Microseconds
UnitInterval = Decimal
Volume = UnitInterval
Rate = UnitInterval
type PlaylistId = str
type PlaylistName = str
type PlaylistIcon = str
type PlaylistEntry = tuple[PlaylistId, PlaylistName, PlaylistIcon]
type PlaylistValidity = bool
type ActivePlaylist = tuple[PlaylistValidity, PlaylistEntry]
# python, d-bus and mpris types
type PyType = type | GenericAliases
type DbusPyTypes = str | float | int | bool | list | Decimal
type PropertyValues = dict[Property, DbusPyTypes]
type DbusMetadata = dict[Property, Variant]
type DbusType = str
type DbusObj = str
type Method[S: Self, **P, T] = Callable[Concatenate[S, P], T]
DEFAULT_RATE: Final[Rate] = Rate(1.0)
PAUSE_RATE: Final[Rate] = Rate(0.0)
MIN_RATE: Final[Rate] = Rate(1.0)
MAX_RATE: Final[Rate] = Rate(1.0)
MUTE_VOLUME: Final[Volume] = Volume(0.0)
MAX_VOLUME: Final[Volume] = Volume(1.0)
class Interface(StrEnum):
Root = INTERFACE
Player = f'{Root}.Player'
TrackList = f'{Root}.TrackList'
Playlists = f'{Root}.Playlists'
class PlayState(StrEnum):
PAUSED = auto()
PLAYING = auto()
STOPPED = auto()
class DbusTypes(StrEnum):
BOOLEAN: DbusType = 'b'
STRING: DbusType = 's'
DATETIME: DbusType = STRING
DOUBLE: DbusType = 'd'
INT32: DbusType = 'i'
INT64: DbusType = 'x'
OBJ: DbusType = 'o'
UINT32: DbusType = 'u'
UINT64: DbusType = 't'
VARIANT: DbusType = 'v'
ARRAY: DbusType = 'a'
MAP: DbusType = f'{ARRAY}{{}}'
OBJ_ARRAY: DbusType = f'{ARRAY}{OBJ}'
STRING_ARRAY: DbusType = f'{ARRAY}{STRING}'
METADATA_ENTRY: DbusType = f'{{{STRING}{VARIANT}}}'
METADATA: DbusType = f'{ARRAY}{METADATA_ENTRY}'
METADATA_ARRAY: DbusType = f'{ARRAY}{METADATA}'
PLAYLIST: DbusType = f'({OBJ}{STRING}{STRING})'
MAYBE_PLAYLIST: DbusType = f'({BOOLEAN}{PLAYLIST})'
PLAYLISTS: DbusType = f'{ARRAY}{PLAYLIST}'
class _MprisTypes(NamedTuple):
ARRAY: PyType = Sequence
BOOLEAN: PyType = bool
DATETIME: PyType = str
DOUBLE: PyType = float
INT32: PyType = int
INT64: PyType = int
MAP: PyType = Mapping
OBJ: PyType = str
OBJ_ARRAY: PyType = Sequence[str]
STRING: PyType = str
STRING_ARRAY: PyType = Sequence[str]
UINT32: PyType = int
UINT64: PyType = int
VARIANT: PyType = object
MAYBE_PLAYLIST: PyType = PlaylistEntry | None
METADATA: PyType = DbusMetadata
METADATA_ARRAY: PyType = Sequence[DbusMetadata]
PLAYLIST: PyType = PlaylistEntry
PLAYLISTS: PyType = Sequence[PlaylistEntry]
MprisTypes: Final = _MprisTypes()
type Compatible = Union[*MprisTypes]
class Artist(NamedTuple):
name: str = DEFAULT_ARTIST_NAME
class Album(NamedTuple):
art_url: str | None = None
artists: Sequence[Artist] = NO_ARTISTS
name: str = DEFAULT_ALBUM_NAME
class Track(NamedTuple):
album: Album | None = None
art_url: str | None = None
artists: Sequence[Artist] = NO_ARTISTS
comments: list[str] | None = None
disc_number: int | None = None
length: Duration = DEFAULT_TRACK_LENGTH
name: str = DEFAULT_TRACK_NAME
track_id: DbusObj = DEFAULT_TRACK_ID
track_number: int | None = None
type: Enum | None = None
uri: str | None = None
def emit_properties_changed[I: MprisInterface](
interface: I,
changed_properties: PropertyValues,
invalidated_properties: Properties = INVALIDATED_PROPERTIES,
):
interface.PropertiesChanged(
interface.INTERFACE,
changed_properties,
invalidated_properties,
)
def get_changed_properties[I: MprisInterface](interface: I, changes: Changes) -> PropertyValues:
return {
prop: getattr(interface, prop)
for prop in changes
}
def dbus_emit_changes[I: MprisInterface](interface: I, changes: Changes):
if not all(change in Property for change in changes):
raise ValueError(f"Invalid property in {changes=}")
changed_properties = get_changed_properties(interface, changes)
emit_properties_changed(interface, changed_properties)
mpris_server-0.9.0/mpris_server/enums.py 0000664 0000000 0000000 00000004140 14533464522 0020470 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from enum import auto
from strenum import LowercaseStrEnum, StrEnum
__all__ = [
'Access',
'Arg',
'BusType',
'Direction',
'LoopStatus',
'Method',
'Property',
'Signal',
]
class Access(LowercaseStrEnum):
READ = auto()
READWRITE = auto()
class Arg(StrEnum):
AfterTrack = auto()
CurrentTrack = auto()
Index = auto()
MaxCount = auto()
Metadata = auto()
Offset = auto()
Order = auto()
Playlist = auto()
PlaylistId = auto()
Playlists = auto()
Position = auto()
ReverseOrder = auto()
SetAsCurrent = auto()
TrackId = auto()
TrackIds = auto()
Tracks = auto()
Uri = auto()
class BusType(LowercaseStrEnum):
SESSION = auto()
SYSTEM = auto()
DEFAULT = SESSION
class Direction(LowercaseStrEnum):
IN = auto()
OUT = auto()
class LoopStatus(StrEnum):
NONE = 'None'
TRACK = 'Track'
PLAYLIST = 'Playlist'
class Method(StrEnum):
ActivatePlaylist = auto()
AddTrack = auto()
GetPlaylists = auto()
GetTracksMetadata = auto()
GoTo = auto()
Next = auto()
OpenUri = auto()
Pause = auto()
Play = auto()
PlayPause = auto()
Previous = auto()
Quit = auto()
Raise = auto()
RemoveTrack = auto()
Seek = auto()
SetPosition = auto()
Stop = auto()
class Property(StrEnum):
ActivePlaylist = auto()
CanControl = auto()
CanEditTracks = auto()
CanGoNext = auto()
CanGoPrevious = auto()
CanPause = auto()
CanPlay = auto()
CanQuit = auto()
CanRaise = auto()
CanSeek = auto()
CanSetFullscreen = auto()
DesktopEntry = auto()
Fullscreen = auto()
HasTrackList = auto()
Identity = auto()
LoopStatus = auto()
MaximumRate = auto()
Metadata = auto()
MinimumRate = auto()
Orderings = auto()
PlaybackStatus = auto()
PlaylistCount = auto()
Position = auto()
Rate = auto()
Shuffle = auto()
SupportedMimeTypes = auto()
SupportedUriSchemes = auto()
Tracks = auto()
Volume = auto()
class Signal(StrEnum):
PlaylistChanged = auto()
PropertiesChanged = auto()
Seeked = auto()
TrackAdded = auto()
TrackListReplaced = auto()
TrackMetadataChanged = auto()
TrackRemoved = auto()
mpris_server-0.9.0/mpris_server/events.py 0000664 0000000 0000000 00000010415 14533464522 0020647 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from abc import ABC, abstractmethod
from typing import override
from .base import Changes, DbusObj, ON_ENDED_PROPS, ON_OPTION_PROPS, ON_PLAYBACK_PROPS, ON_PLAYER_PROPS, \
ON_PLAYLIST_PROPS, ON_PLAYPAUSE_PROPS, ON_ROOT_PROPS, ON_SEEK_PROPS, ON_TITLE_PROPS, ON_TRACKS_PROPS, ON_VOLUME_PROPS, \
Position, dbus_emit_changes
from .interfaces.interface import MprisInterface
from .interfaces.player import Player
from .interfaces.playlists import Playlists
from .interfaces.root import Root
from .interfaces.tracklist import TrackList
from .mpris.metadata import Metadata
__all__ = [
'BaseEventAdapter',
'Changes',
'EventAdapter',
'PlayerEventAdapter',
'PlaylistsEventAdapter',
'RootEventAdapter',
'TracklistEventAdapter',
]
class BaseEventAdapter(ABC):
root: Root
player: Player | None
playlist: Playlists | None
tracklist: TrackList | None
def __init__(
self,
root: Root,
player: Player | None = None,
playlists: Playlists | None = None,
tracklist: TrackList | None = None,
):
self.root = root
self.player = player
self.playlist = playlists
self.tracklist = tracklist
@staticmethod
def emit_changes[I: MprisInterface](interface: I, changes: Changes):
dbus_emit_changes(interface, changes)
@abstractmethod
def emit_all(self):
"""Emit all changes for all adapters in hierarchy"""
pass
class RootEventAdapter(BaseEventAdapter, ABC):
@override
def emit_all(self):
self.on_root_all()
super().emit_all()
def emit_root_changes(self, changes: Changes):
self.emit_changes(self.root, changes)
def on_root_all(self):
self.emit_root_changes(ON_ROOT_PROPS)
class PlayerEventAdapter(BaseEventAdapter, ABC):
@override
def emit_all(self):
self.on_player_all()
super().emit_all()
def on_player_all(self):
self.emit_player_changes(ON_PLAYER_PROPS)
def emit_player_changes(self, changes: Changes):
self.emit_changes(self.player, changes)
def on_ended(self):
self.emit_player_changes(ON_ENDED_PROPS)
def on_volume(self):
self.emit_player_changes(ON_VOLUME_PROPS)
def on_playback(self):
self.emit_player_changes(ON_PLAYBACK_PROPS)
def on_playpause(self):
self.emit_player_changes(ON_PLAYPAUSE_PROPS)
def on_title(self):
self.emit_player_changes(ON_TITLE_PROPS)
def on_seek(self, position: Position):
self.player.Seeked(position)
self.emit_player_changes(ON_SEEK_PROPS)
def on_options(self):
self.emit_player_changes(ON_OPTION_PROPS)
class PlaylistsEventAdapter(BaseEventAdapter, ABC):
@override
def emit_all(self):
self.on_playlists_all()
super().emit_all()
def emit_playlist_changes(self, changes: Changes):
self.emit_changes(self.playlist, changes)
def on_playlists_all(self):
self.emit_playlist_changes(ON_PLAYLIST_PROPS)
def on_playlist_change(self, playlist_id: DbusObj):
self.playlist.PlaylistChanged(playlist_id)
self.emit_playlist_changes(ON_PLAYLIST_PROPS)
class TracklistEventAdapter(BaseEventAdapter, ABC):
@override
def emit_all(self):
self.on_tracklist_all()
super().emit_all()
def emit_tracklist_changes(self, changes: Changes):
self.emit_changes(self.tracklist, changes)
def on_tracklist_all(self):
self.emit_tracklist_changes(ON_TRACKS_PROPS)
def on_list_replaced(self, tracks: list[DbusObj], current_track: DbusObj):
self.tracklist.TrackListReplaced(tracks, current_track)
self.emit_tracklist_changes(ON_TRACKS_PROPS)
def on_track_added(self, metadata: Metadata, after_track: DbusObj):
self.tracklist.TrackAdded(metadata, after_track)
self.emit_tracklist_changes(ON_TRACKS_PROPS)
def on_track_removed(self, track_id: DbusObj):
self.tracklist.TrackRemoved(track_id)
self.emit_tracklist_changes(ON_TRACKS_PROPS)
def on_track_metadata_change(self, track_id: DbusObj, metadata: Metadata):
self.tracklist.TrackMetadataChanged(track_id, metadata)
self.emit_tracklist_changes(ON_TRACKS_PROPS)
class EventAdapter(
RootEventAdapter,
PlayerEventAdapter,
TracklistEventAdapter,
PlaylistsEventAdapter,
ABC,
):
'''
Notify D-Bus of state-change events in the media player.
Implement this class and integrate it in your application to emit
D-Bus signals when there are state changes in the media player.
'''
pass
mpris_server-0.9.0/mpris_server/interfaces/ 0000775 0000000 0000000 00000000000 14533464522 0021113 5 ustar 00root root 0000000 0000000 mpris_server-0.9.0/mpris_server/interfaces/__init__.py 0000664 0000000 0000000 00000000634 14533464522 0023227 0 ustar 00root root 0000000 0000000 from .interface import MprisInterface
from .player import Player
from .playlists import Playlists
from .root import Root, get_desktop_entry
from .tracklist import TrackList
from . import interface, player, playlists, root, tracklist
__all__ = [
'get_desktop_entry',
'interface',
'MprisInterface',
'Player',
'player',
'Playlists',
'playlists',
'Root',
'root',
'TrackList',
'tracklist',
]
mpris_server-0.9.0/mpris_server/interfaces/interface.py 0000664 0000000 0000000 00000001763 14533464522 0023434 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import logging
from abc import ABC
from functools import wraps
from typing import ClassVar, Final, Self, TYPE_CHECKING
from pydbus.generic import signal
from ..base import Interface, Method, NAME
if TYPE_CHECKING:
from ..adapters import MprisAdapter
log = logging.getLogger(__name__)
def log_trace[S: Self, **P, T](method: Method) -> Method:
@wraps(method)
def new_method(self: S, *args: P.args, **kwargs: P.kwargs) -> T:
name = method.__name__
func = f'{self.INTERFACE}.{name}()'
log.debug(f'{func} called.')
if (result := method(self, *args, **kwargs)) is not None:
log.debug(f'{func} result: {result}')
return result
return new_method
class MprisInterface[A: MprisAdapter](ABC):
INTERFACE: ClassVar[Interface] = Interface.Root
name: str
adapter: A | None
PropertiesChanged: Final[signal] = signal()
def __init__(self, name: str = NAME, adapter: A | None = None):
self.name = name
self.adapter = adapter
mpris_server-0.9.0/mpris_server/interfaces/player.py 0000664 0000000 0000000 00000025416 14533464522 0022771 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import logging
from fractions import Fraction
from typing import ClassVar, Final
from pydbus.generic import signal
from .interface import MprisInterface, log_trace
from ..base import BEGINNING, DbusObj, DbusTypes, Interface, MAX_RATE, MAX_VOLUME, MIN_RATE, MUTE_VOLUME, \
PAUSE_RATE, PlayState, Position, Rate, Track, Volume
from ..enums import Access, Arg, Direction, LoopStatus, Method, Property, Signal
from ..mpris.metadata import Metadata, MetadataEntries, create_metadata_from_track, get_dbus_metadata, update_metadata
log = logging.getLogger(__name__)
ERR_NOT_ENOUGH_METADATA: Final[str] = \
"Couldn't find enough metadata, please implement metadata() or get_stream_title() and get_current_track() methods.`"
class Player(MprisInterface):
INTERFACE: ClassVar[Interface] = Interface.Player
__doc__: Final[str] = f"""
"""
Seeked: Final[signal] = signal()
def _get_metadata(self) -> Metadata | None:
if metadata := self.adapter.metadata():
return get_dbus_metadata(metadata)
return None
def _get_basic_metadata(self, track: Track) -> Metadata:
metadata: Metadata = Metadata()
if name := self.adapter.get_stream_title():
update_metadata(metadata, MetadataEntries.TITLE, name)
if art_url := self._get_art_url(track):
update_metadata(metadata, MetadataEntries.ART_URL, art_url)
return metadata
def _get_art_url(self, track: DbusObj | Track | None) -> str:
return self.adapter.get_art_url(track)
@property
@log_trace
def CanControl(self) -> bool:
return self.adapter.can_control()
@property
@log_trace
def CanGoNext(self) -> bool:
# if not self.CanControl:
# return False
return self.adapter.can_go_next()
@property
@log_trace
def CanGoPrevious(self) -> bool:
# if not self.CanControl:
# return False
return self.adapter.can_go_previous()
@property
@log_trace
def CanPause(self) -> bool:
return self.adapter.can_pause()
# if not self.CanControl:
# return False
# return True
@property
@log_trace
def CanPlay(self) -> bool:
# if not self.CanControl:
# return False
return self.adapter.can_play()
@property
@log_trace
def CanSeek(self) -> bool:
return self.adapter.can_seek()
# if not self.CanControl:
# return False
# return True
@property
@log_trace
def LoopStatus(self) -> LoopStatus:
if not self.adapter.is_repeating():
return LoopStatus.NONE
elif not self.adapter.is_playlist():
return LoopStatus.TRACK
else:
return LoopStatus.PLAYLIST
@LoopStatus.setter
@log_trace
def LoopStatus(self, value: LoopStatus):
if not self.CanControl:
log.debug(f"Setting {self.INTERFACE}.{Property.LoopStatus} not allowed")
return
log.debug(f"Setting {self.INTERFACE}.{Property.LoopStatus} to {value}")
self.adapter.set_loop_status(value)
@property
@log_trace
def MinimumRate(self) -> Rate:
if rate := self.adapter.get_minimum_rate():
return rate
return MIN_RATE
@property
@log_trace
def MaximumRate(self) -> Rate:
if rate := self.adapter.get_maximum_rate():
return rate
return MAX_RATE
@property
@log_trace
def Metadata(self) -> Metadata:
# prefer adapter's metadata to building our own
if metadata := self._get_metadata():
return metadata
# build metadata if no metadata supplied by adapter
log.debug(f"Building {self.INTERFACE}.{Property.Metadata}")
track = self.adapter.get_current_track()
metadata: Metadata = self._get_basic_metadata(track)
if not track:
log.warning(ERR_NOT_ENOUGH_METADATA)
return metadata
return create_metadata_from_track(track, metadata)
@property
@log_trace
def PlaybackStatus(self) -> PlayState:
state = self.adapter.get_playstate()
return state.value.title()
@property
@log_trace
def Position(self) -> Position:
return self.adapter.get_current_position()
@property
@log_trace
def Rate(self) -> Rate:
return self.adapter.get_rate()
@Rate.setter
@log_trace
def Rate(self, value: Rate):
if not self.CanControl:
log.debug(f"Setting {self.INTERFACE}.Rate not allowed")
return
self.adapter.set_rate(value)
if value == PAUSE_RATE:
self.Pause()
@property
@log_trace
def Shuffle(self) -> bool:
return self.adapter.get_shuffle()
@Shuffle.setter
@log_trace
def Shuffle(self, value: bool):
if not self.CanControl:
log.debug(f"Setting {self.INTERFACE}.{Property.Shuffle} not allowed")
return
log.debug(f"Setting {self.INTERFACE}.{Property.Shuffle} to {value}")
self.adapter.set_shuffle(value)
@property
@log_trace
def Volume(self) -> Volume:
if self.adapter.is_mute():
return MUTE_VOLUME
match volume := self.adapter.get_volume():
case Volume():
return volume
case _ if not volume:
return MUTE_VOLUME
return volume
@Volume.setter
@log_trace
def Volume(self, value: Volume | None):
if not self.CanControl:
log.debug(f"Setting {self.INTERFACE}.{Property.Volume} not allowed")
return
match volume := value:
case None:
return
case Volume() | int() | float() | Fraction():
volume = Volume(volume)
case unknown:
log.warning(f"Unknown volume type: {type(unknown)}, continuing anyway.")
if volume < MUTE_VOLUME:
volume = MUTE_VOLUME
elif volume > MAX_VOLUME:
volume = MAX_VOLUME
self.adapter.set_volume(volume)
if volume > MUTE_VOLUME:
self.adapter.set_mute(False)
elif volume <= MUTE_VOLUME:
self.adapter.set_mute(True)
@log_trace
def Next(self):
if not self.CanGoNext:
log.debug(f"{self.INTERFACE}.{Method.Next} not allowed")
return
self.adapter.next()
@log_trace
def OpenUri(self, uri: str):
if not self.CanControl:
log.debug(f"{self.INTERFACE}.{Method.OpenUri} not allowed")
return
# NOTE Check if URI has MIME type known to the backend, if MIME support
# is added to the backend.
self.adapter.open_uri(uri)
@log_trace
def Previous(self):
if not self.CanGoPrevious:
log.debug(f"{self.INTERFACE}.{Method.Previous} not allowed")
return
self.adapter.previous()
@log_trace
def Pause(self):
if not self.CanPause:
log.debug(f"{self.INTERFACE}.{Method.Pause} not allowed")
return
self.adapter.pause()
@log_trace
def Play(self):
if not self.CanPlay:
log.debug(f"{self.INTERFACE}.{Method.Play} not allowed")
return
match self.adapter.get_playstate():
case PlayState.PAUSED:
self.adapter.resume()
case _:
self.adapter.play()
@log_trace
def PlayPause(self):
if not self.CanPause:
log.debug(f"{self.INTERFACE}.{Method.PlayPause} not allowed")
return
match self.adapter.get_playstate():
case PlayState.PLAYING:
self.adapter.pause()
case PlayState.PAUSED:
self.adapter.resume()
case PlayState.STOPPED:
self.adapter.play()
@log_trace
def Seek(self, offset: Position):
if not self.CanSeek:
log.debug(f"{self.INTERFACE}.{Method.Seek} not allowed")
return
current_position = self.adapter.get_current_position()
new_position = current_position + offset
if new_position < BEGINNING:
new_position = BEGINNING
self.adapter.seek(new_position)
@log_trace
def SetPosition(self, track_id: str, position: Position):
if not self.CanSeek:
log.debug(f"{self.INTERFACE}.{Method.SetPosition} not allowed")
return
self.adapter.seek(position, track_id=track_id)
# metadata = self.adapter.metadata()
# current_track: Optional[Track] = None
##use metadata from adapter if available
# if metadata \
# and 'mpris:trackid' in metadata \
# and 'mpris:length' in metadata:
# current_track = Track(
# track_id=metadata['mpris:trackid'],
# length=metadata['mpris:length']
# )
##if no metadata, build metadata from Track interface
# else:
# current_track = self.adapter.get_current_track()
# if current_track is None:
# return
# if track_id != current_track.track_id:
# return
# if position < BEGINNING:
# return
# if current_track.length < position:
# return
# self.adapter.seek(position, track_id=track_id)
@log_trace
def Stop(self):
if not self.CanControl:
log.debug(f"{self.INTERFACE}.{Method.Stop} not allowed")
return
self.adapter.stop()
mpris_server-0.9.0/mpris_server/interfaces/playlists.py 0000664 0000000 0000000 00000004461 14533464522 0023516 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import logging
from typing import ClassVar, Final
from pydbus.generic import signal
from .interface import MprisInterface, log_trace
from ..base import ActivePlaylist, DbusTypes, Interface, Ordering, PlaylistEntry, PlaylistId
from ..enums import Access, Arg, Direction, Method, Property, Signal
log = logging.getLogger(__name__)
class Playlists(MprisInterface):
INTERFACE: ClassVar[Interface] = Interface.Playlists
__doc__: Final[str] = f"""
"""
PlaylistChanged: Final[signal] = signal()
@property
@log_trace
def ActivePlaylist(self) -> ActivePlaylist:
return self.adapter.get_active_playlist()
@property
@log_trace
def Orderings(self) -> list[Ordering]:
return self.adapter.get_orderings()
@property
@log_trace
def PlaylistCount(self) -> int:
return self.adapter.get_playlist_count()
@log_trace
def ActivatePlaylist(self, playlist_id: PlaylistId):
self.adapter.activate_playlist(playlist_id)
@log_trace
def GetPlaylists(self, index: int, max_count: int, order: str, reverse: bool) -> list[PlaylistEntry]:
return self.adapter.get_playlists(index, max_count, order, reverse)
mpris_server-0.9.0/mpris_server/interfaces/root.py 0000664 0000000 0000000 00000005651 14533464522 0022457 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from pathlib import PurePath
from typing import ClassVar, Final
from .interface import MprisInterface, log_trace
from ..base import DbusTypes, Interface, Paths
from ..enums import Access, Method, Property
NO_SUFFIX: Final[str] = ''
DESKTOP_EXT: Final[str] = '.desktop'
NO_DESKTOP_ENTRY: Final[str] = ''
class Root(MprisInterface):
INTERFACE: ClassVar[Interface] = Interface.Root
__doc__: Final[str] = f"""
"""
@property
@log_trace
def CanQuit(self) -> bool:
return self.adapter.can_quit()
@property
@log_trace
def CanRaise(self) -> bool:
return self.adapter.can_raise()
@property
@log_trace
def CanSetFullscreen(self) -> bool:
return self.adapter.can_fullscreen()
@property
@log_trace
def DesktopEntry(self) -> str:
path: Paths = self.adapter.get_desktop_entry()
return get_desktop_entry(path)
@property
@log_trace
def Fullscreen(self) -> bool:
return self.adapter.get_fullscreen()
@Fullscreen.setter
@log_trace
def Fullscreen(self, value: bool):
self.adapter.set_fullscreen(value)
@property
@log_trace
def HasTrackList(self) -> bool:
return self.adapter.has_tracklist()
@property
@log_trace
def Identity(self) -> str:
return self.name
@property
@log_trace
def SupportedMimeTypes(self) -> list[str]:
return self.adapter.get_mime_types()
@property
@log_trace
def SupportedUriSchemes(self) -> list[str]:
return self.adapter.get_uri_schemes()
@log_trace
def Quit(self):
self.adapter.quit()
@log_trace
def Raise(self):
self.adapter.set_raise(True)
def get_desktop_entry(path: Paths | None) -> str:
if not path:
return NO_DESKTOP_ENTRY
# mpris requires stripped suffix
if isinstance(path, PurePath):
path = path.with_suffix(NO_SUFFIX)
name = str(path)
if name.endswith(DESKTOP_EXT):
name = name.rstrip(DESKTOP_EXT)
return name
mpris_server-0.9.0/mpris_server/interfaces/tracklist.py 0000664 0000000 0000000 00000006052 14533464522 0023470 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from typing import ClassVar, Final
from pydbus.generic import signal
from .interface import MprisInterface
from ..base import DbusObj, DbusTypes, Interface, NoTrack
from ..enums import Access, Arg, Direction, Method, Property, Signal
from ..mpris.metadata import Metadata
class TrackList(MprisInterface):
INTERFACE: ClassVar[Interface] = Interface.TrackList
__doc__: Final[str] = f"""
"""
TrackAdded: Final[signal] = signal()
TrackListReplaced: Final[signal] = signal()
TrackMetadataChanged: Final[signal] = signal()
TrackRemoved: Final[signal] = signal()
@property
def CanEditTracks(self) -> bool:
return self.adapter.can_edit_tracks()
@property
def Tracks(self) -> list[DbusObj]:
if not (tracks := self.adapter.get_tracks()):
return [NoTrack]
return tracks
def AddTrack(
self,
uri: str,
after_track: DbusObj,
set_as_current: bool
):
self.adapter.add_track(uri, after_track, set_as_current)
def GetTracksMetadata(self, track_ids: list[DbusObj]) -> list[Metadata]:
return self.adapter.get_tracks_metadata(track_ids)
def GoTo(self, track_id: DbusObj):
self.adapter.go_to(track_id)
def RemoveTrack(self, track_id: DbusObj):
self.adapter.remove_track(track_id)
mpris_server-0.9.0/mpris_server/mpris/ 0000775 0000000 0000000 00000000000 14533464522 0020122 5 ustar 00root root 0000000 0000000 mpris_server-0.9.0/mpris_server/mpris/__init__.py 0000664 0000000 0000000 00000001352 14533464522 0022234 0 ustar 00root root 0000000 0000000 from . import compat, metadata
from .compat import enforce_dbus_length, get_dbus_name, get_track_id, DBUS_NAME_MAX
from .metadata import (
DEFAULT_METADATA, Name, Metadata, MetadataEntry, NameMetadata, SortedMetadata,
MetadataEntries, MetadataTypes, MetadataObj, ValidMetadata, get_runtime_types,
is_dbus_type, is_valid_metadata, get_dbus_metadata
)
__all__ = [
'compat',
'DBUS_NAME_MAX',
'DEFAULT_METADATA',
'enforce_dbus_length',
'get_dbus_metadata',
'get_dbus_name',
'get_runtime_types',
'get_track_id',
'is_dbus_type',
'is_valid_metadata',
'metadata',
'Metadata',
'MetadataEntries',
'MetadataEntry',
'MetadataObj',
'MetadataTypes',
'Name',
'NameMetadata',
'SortedMetadata',
'ValidMetadata',
]
mpris_server-0.9.0/mpris_server/mpris/compat.py 0000664 0000000 0000000 00000004271 14533464522 0021763 0 ustar 00root root 0000000 0000000 # Python and DBus compatibility
# See: https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata/
from __future__ import annotations
from collections.abc import Callable, Sequence
from functools import wraps
from random import choices
from typing import Final
from emoji import demojize, emoji_count
from unidecode import unidecode
from ..base import DbusObj, NAME_PREFIX, RAND_CHARS, VALID_CHARS
DBUS_NAME_MAX: Final[int] = 255
START_WITH: Final[str] = "_"
FIRST_CHAR: Final[int] = 0
# following must be subscriptable to be used with random.choices()
VALID_CHARS_SUB: Final[Sequence[str]] = tuple(VALID_CHARS)
INTERFACE_CHARS: Final[set[str]] = {*VALID_CHARS, '-'}
type ReturnsStr[**P] = Callable[P, str]
def to_ascii(text: str) -> str:
if emoji_count(text):
text = demojize(text)
return unidecode(text)
def random_name() -> str:
chars = choices(VALID_CHARS_SUB, k=RAND_CHARS)
name = ''.join(chars)
return NAME_PREFIX + name
def enforce_dbus_length[**P](func: ReturnsStr) -> ReturnsStr:
@wraps(func)
def new_func(*args: P.args, **kwargs: P.kwargs) -> str:
val: str = func(*args, **kwargs)
return val[:DBUS_NAME_MAX]
return new_func
@enforce_dbus_length
def get_dbus_name(
name: str | None = None,
is_interface: bool = False,
) -> str:
if not name:
return get_dbus_name(random_name())
# interface names can contain hyphens
valid_chars = INTERFACE_CHARS if is_interface else VALID_CHARS
# convert utf8 to ascii
new_name = to_ascii(name)
# new name shouldn't have spaces
new_name = new_name.replace(' ', '_')
# new name should only contain D-Bus valid chars
new_name = ''.join(
char
for char in new_name
if char in valid_chars
)
# D-Bus names can't start with numbers
if new_name and new_name[FIRST_CHAR].isnumeric():
# just stick an underscore in front of the number
return START_WITH + new_name
# but they can start with letters or underscores
elif new_name:
return new_name
# if there is no name left after normalizing,
# then make a random one and validate it
return get_dbus_name(random_name())
@enforce_dbus_length
def get_track_id(name: str) -> DbusObj:
return f'/track/{get_dbus_name(name)}'
mpris_server-0.9.0/mpris_server/mpris/metadata.py 0000664 0000000 0000000 00000031053 14533464522 0022256 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import logging
from collections.abc import Collection, Iterable, Mapping, Sequence
from typing import Any, Final, NamedTuple, Required, Self, TypedDict, cast
from gi.repository.GLib import Variant
from strenum import StrEnum
from ..base import Artist, Compatible, DEFAULT_TRACK_ID, DbusPyTypes, DbusTypes, MprisTypes, NO_ARTIST_NAME, PyType, \
Track
from ..types import get_type, is_type
log = logging.getLogger(__name__)
FIRST: Final[int] = 0
FIELDS_ERROR: Final[str] = "Added or missing fields."
type Name = str
type MetadataEntry = str
type NameMetadata = tuple[Name, DbusPyTypes]
type SortedMetadata = dict[Name, DbusPyTypes]
class MetadataEntries(StrEnum):
ALBUM: MetadataEntry = "xesam:album"
ALBUM_ARTISTS: MetadataEntry = "xesam:albumArtist"
ART_URL: MetadataEntry = "mpris:artUrl"
ARTISTS: MetadataEntry = "xesam:artist"
AS_TEXT: MetadataEntry = 'xesam:asText'
AUDIO_BPM: MetadataEntry = 'xesam:audioBPM'
AUTO_RATING: MetadataEntry = 'xesam:autoRating'
COMMENT: MetadataEntry = "xesam:comment"
COMPOSER: MetadataEntry = "xesam:composer"
CONTENT_CREATED: MetadataEntry = "xesam:contentCreated"
DISC_NUMBER: MetadataEntry = "xesam:discNumber"
FIRST_USED: MetadataEntry = "xesam:firstUsed"
GENRE: MetadataEntry = "xesam:genre"
LAST_USED: MetadataEntry = "xesam:lastUsed"
LENGTH: MetadataEntry = "mpris:length"
LYRICIST: MetadataEntry = "xesam:lyricist"
TITLE: MetadataEntry = "xesam:title"
TRACK_ID: MetadataEntry = "mpris:trackid"
TRACK_NUMBER: MetadataEntry = "xesam:trackNumber"
URL: MetadataEntry = "xesam:url"
USE_COUNT: MetadataEntry = "xesam:useCount"
USER_RATING: MetadataEntry = "xesam:userRating"
@classmethod
def sorted(cls: type[Self]) -> list[Self]:
return sorted(cls, key=sort_enum_by_name)
@classmethod
def to_dict(cls: type[Self]) -> dict[str, Self]:
return {
enum.name: enum.value
for enum in cls.sorted()
}
# map of D-Bus metadata entries and their D-Bus types
METADATA_TYPES: Final[dict[MetadataEntries, DbusTypes]] = {
MetadataEntries.ALBUM: DbusTypes.STRING,
MetadataEntries.ALBUM_ARTISTS: DbusTypes.STRING_ARRAY,
MetadataEntries.ART_URL: DbusTypes.STRING,
MetadataEntries.ARTISTS: DbusTypes.STRING_ARRAY,
MetadataEntries.AS_TEXT: DbusTypes.STRING_ARRAY,
MetadataEntries.AUDIO_BPM: DbusTypes.INT32,
MetadataEntries.AUTO_RATING: DbusTypes.DOUBLE,
MetadataEntries.COMMENT: DbusTypes.STRING_ARRAY,
MetadataEntries.COMPOSER: DbusTypes.STRING_ARRAY,
MetadataEntries.CONTENT_CREATED: DbusTypes.STRING,
MetadataEntries.DISC_NUMBER: DbusTypes.INT32,
MetadataEntries.FIRST_USED: DbusTypes.STRING,
MetadataEntries.GENRE: DbusTypes.STRING_ARRAY,
MetadataEntries.LAST_USED: DbusTypes.STRING_ARRAY,
MetadataEntries.LENGTH: DbusTypes.INT64,
MetadataEntries.LYRICIST: DbusTypes.STRING_ARRAY,
MetadataEntries.TITLE: DbusTypes.STRING,
MetadataEntries.TRACK_ID: DbusTypes.STRING,
MetadataEntries.TRACK_NUMBER: DbusTypes.INT32,
MetadataEntries.URL: DbusTypes.STRING,
MetadataEntries.USE_COUNT: DbusTypes.INT32,
MetadataEntries.USER_RATING: DbusTypes.DOUBLE,
}
DBUS_TYPES_TO_PY_TYPES: Final[dict[DbusTypes, PyType]] = {
DbusTypes.BOOLEAN: MprisTypes.BOOLEAN,
DbusTypes.DATETIME: MprisTypes.DATETIME,
DbusTypes.DOUBLE: MprisTypes.DOUBLE,
DbusTypes.INT32: MprisTypes.INT32,
DbusTypes.INT64: MprisTypes.INT64,
DbusTypes.OBJ: MprisTypes.OBJ,
DbusTypes.OBJ_ARRAY: MprisTypes.OBJ_ARRAY,
DbusTypes.STRING: MprisTypes.STRING,
DbusTypes.STRING_ARRAY: MprisTypes.STRING_ARRAY,
DbusTypes.UINT32: MprisTypes.UINT32,
DbusTypes.UINT64: MprisTypes.UINT64,
}
METADATA_TO_PY_TYPES: Final[dict[MetadataEntries, PyType]] = {
MetadataEntries.ALBUM: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING],
MetadataEntries.ALBUM_ARTISTS: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY],
MetadataEntries.ART_URL: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING],
MetadataEntries.ARTISTS: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY],
MetadataEntries.AS_TEXT: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY],
MetadataEntries.AUDIO_BPM: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY],
MetadataEntries.AUTO_RATING: DBUS_TYPES_TO_PY_TYPES[DbusTypes.DOUBLE],
MetadataEntries.COMMENT: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY],
MetadataEntries.COMPOSER: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY],
MetadataEntries.CONTENT_CREATED: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING],
MetadataEntries.DISC_NUMBER: DBUS_TYPES_TO_PY_TYPES[DbusTypes.INT32],
MetadataEntries.FIRST_USED: DBUS_TYPES_TO_PY_TYPES[DbusTypes.DATETIME],
MetadataEntries.GENRE: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY],
MetadataEntries.LAST_USED: DBUS_TYPES_TO_PY_TYPES[DbusTypes.DATETIME],
MetadataEntries.LENGTH: DBUS_TYPES_TO_PY_TYPES[DbusTypes.INT64],
MetadataEntries.LYRICIST: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING_ARRAY],
MetadataEntries.TITLE: Required[DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING]],
MetadataEntries.TRACK_ID: DBUS_TYPES_TO_PY_TYPES[DbusTypes.OBJ],
MetadataEntries.TRACK_NUMBER: DBUS_TYPES_TO_PY_TYPES[DbusTypes.INT32],
MetadataEntries.URL: DBUS_TYPES_TO_PY_TYPES[DbusTypes.STRING],
MetadataEntries.USE_COUNT: DBUS_TYPES_TO_PY_TYPES[DbusTypes.INT32],
MetadataEntries.USER_RATING: DBUS_TYPES_TO_PY_TYPES[DbusTypes.DOUBLE],
}
Metadata = TypedDict('Metadata', METADATA_TO_PY_TYPES, total=False)
DEFAULT_METADATA: Final[Metadata] = Metadata()
assert (
len(MetadataEntries) ==
len(METADATA_TYPES) ==
len(METADATA_TO_PY_TYPES) ==
len(Metadata.__annotations__)
), FIELDS_ERROR
class _MetadataTypes(NamedTuple):
ALBUM: PyType = METADATA_TO_PY_TYPES[MetadataEntries.ALBUM]
ALBUM_ARTISTS: PyType = METADATA_TO_PY_TYPES[MetadataEntries.ALBUM_ARTISTS]
ART_URL: PyType = METADATA_TO_PY_TYPES[MetadataEntries.ART_URL]
ARTISTS: PyType = METADATA_TO_PY_TYPES[MetadataEntries.ARTISTS]
AS_TEXT: PyType = METADATA_TO_PY_TYPES[MetadataEntries.AS_TEXT]
AUDIO_BPM: PyType = METADATA_TO_PY_TYPES[MetadataEntries.AUDIO_BPM]
AUTO_RATING: PyType = METADATA_TO_PY_TYPES[MetadataEntries.AUTO_RATING]
COMMENT: PyType = METADATA_TO_PY_TYPES[MetadataEntries.COMMENT]
COMPOSER: PyType = METADATA_TO_PY_TYPES[MetadataEntries.COMPOSER]
CONTENT_CREATED: PyType = METADATA_TO_PY_TYPES[MetadataEntries.CONTENT_CREATED]
DISC_NUMBER: PyType = METADATA_TO_PY_TYPES[MetadataEntries.DISC_NUMBER]
FIRST_USED: PyType = METADATA_TO_PY_TYPES[MetadataEntries.FIRST_USED]
GENRE: PyType = METADATA_TO_PY_TYPES[MetadataEntries.GENRE]
LAST_USED: PyType = METADATA_TO_PY_TYPES[MetadataEntries.FIRST_USED]
LENGTH: PyType = METADATA_TO_PY_TYPES[MetadataEntries.LENGTH]
LYRICIST: PyType = METADATA_TO_PY_TYPES[MetadataEntries.LYRICIST]
TITLE: PyType = METADATA_TO_PY_TYPES[MetadataEntries.TITLE]
TRACK_ID: PyType = METADATA_TO_PY_TYPES[MetadataEntries.TRACK_ID]
TRACK_NUMBER: PyType = METADATA_TO_PY_TYPES[MetadataEntries.TRACK_NUMBER]
URL: PyType = METADATA_TO_PY_TYPES[MetadataEntries.URL]
USE_COUNT: PyType = METADATA_TO_PY_TYPES[MetadataEntries.USE_COUNT]
USER_RATING: PyType = METADATA_TO_PY_TYPES[MetadataEntries.USER_RATING]
MetadataTypes: Final[_MetadataTypes] = _MetadataTypes()
assert len(MetadataEntries) == len(MetadataTypes), FIELDS_ERROR
class MetadataObj(NamedTuple):
album: MetadataTypes.ALBUM | None = None
album_artists: MetadataTypes.ALBUM_ARTISTS | None = None
art_url: MetadataTypes.ART_URL | None = None
artists: MetadataTypes.ARTISTS | None = None
as_text: MetadataTypes.AS_TEXT | None = None
audio_bpm: MetadataTypes.AUDIO_BPM | None = None
auto_rating: MetadataTypes.AUTO_RATING | None = None
comments: MetadataTypes.COMMENT | None = None
composer: MetadataTypes.COMPOSER | None = None
content_created: MetadataTypes.CONTENT_CREATED | None = None
disc_number: MetadataTypes.DISC_NUMBER | None = None
first_used: MetadataTypes.FIRST_USED | None = None
genre: MetadataTypes.GENRE | None = None
last_used: MetadataTypes.LAST_USED | None = None
length: MetadataTypes.LENGTH | None = None
lyricist: MetadataTypes.LYRICIST | None = None
title: MetadataTypes.TITLE | None = None
track_id: MetadataTypes.TRACK_ID = DEFAULT_TRACK_ID
track_number: MetadataTypes.TRACK_NUMBER | None = None
url: MetadataTypes.URL | None = None
use_count: MetadataTypes.USE_COUNT | None = None
user_rating: MetadataTypes.USER_RATING | None = None
def sorted(self) -> SortedMetadata:
items: Iterable[NameMetadata] = self._asdict().items()
items = sorted(items, key=sort_metadata_by_name)
return dict(items)
def to_dict(self) -> Metadata:
entries = MetadataEntries.sorted()
vals = self.sorted().values()
pairs = zip(entries, vals)
# return dict(filter(None, pairs))
return {
entry: metadata
for entry, metadata in pairs
if metadata is not None
}
assert len(MetadataEntries) == len(MetadataObj._fields), FIELDS_ERROR
type ValidMetadata = Metadata | MetadataObj
type RuntimeTypes = tuple[type, ...]
def get_runtime_types() -> RuntimeTypes:
types: set[type] = {
get_type(val)
for val in DBUS_TYPES_TO_PY_TYPES.values()
if is_type(val)
}
return tuple(types)
DBUS_RUNTIME_TYPES: Final[RuntimeTypes] = get_runtime_types()
def is_null_collection(val: Any) -> bool:
if isinstance(val, Collection):
return all(item is None for item in val)
return False
def is_dbus_type(val: Any) -> bool:
return isinstance(val, DBUS_RUNTIME_TYPES)
def is_valid_metadata(entry: str, val: Any) -> bool:
if val is None or entry not in METADATA_TYPES:
log.debug(f"<{entry=}, {val=}> isn't valid metadata, skipping.")
return False
return is_dbus_type(val) and not is_null_collection(val)
def get_dbus_var(entry: MetadataEntries, val: Any) -> Variant:
metadata_type: DbusTypes = METADATA_TYPES[entry]
var: Compatible = to_compatible_type(val)
log.debug(f"Translating <{entry=}, {val=}> to <{metadata_type=}, {var=}>")
return Variant(metadata_type, var)
def to_compatible_type(val: Any) -> Compatible:
match val:
case bytes(string):
return string.decode()
case str(string):
return string
case Mapping() as mapping:
return dict(mapping)
case Sequence() as sequence:
return list(sequence)
return val
def get_dbus_metadata(metadata: ValidMetadata) -> Metadata:
if isinstance(metadata, MetadataObj):
metadata: Metadata = metadata.to_dict()
metadata = cast(Metadata, metadata)
return {
entry: get_dbus_var(entry, value)
for entry, value in metadata.items()
if is_valid_metadata(entry, value)
}
def sort_metadata_by_name(name_metadata: NameMetadata) -> Name:
name, _ = name_metadata
return name.casefold()
def sort_enum_by_name(enum: StrEnum) -> str:
return enum.name.casefold()
def sort_artists_by_name(artist: Artist) -> str:
return artist.name.casefold() or NO_ARTIST_NAME
def get_names(artists: list[Artist]) -> list[str]:
artists = sorted(artists, key=sort_artists_by_name)
return [artist.name for artist in artists if artist.name]
def update_metadata(metadata: Metadata, entry: MetadataEntries, value: Any) -> Metadata:
if value is None:
return metadata
metadata[entry] = get_dbus_var(entry, value)
return metadata
def update_metadata_from_track(track: Track, metadata: Metadata | None = None) -> Metadata:
if metadata is None:
metadata = Metadata()
album, art_url, artists, disc_number, length, name, track_id, track_number, _, uri = track
if name and (entry := MetadataEntries.TITLE) not in metadata:
update_metadata(metadata, entry, name)
if art_url and (entry := MetadataEntries.ART_URL) not in metadata:
update_metadata(metadata, entry, art_url)
if length:
update_metadata(metadata, MetadataEntries.LENGTH, length)
if uri:
update_metadata(metadata, MetadataEntries.URL, uri)
if artists:
names = get_names(artists)
update_metadata(metadata, MetadataEntries.ARTISTS, names)
if album and (artists := album.artists):
names = get_names(artists)
update_metadata(metadata, MetadataEntries.ALBUM_ARTISTS, names)
if album and (name := album.name):
update_metadata(metadata, MetadataEntries.ALBUM, name)
if disc_number:
update_metadata(metadata, MetadataEntries.DISC_NUMBER, disc_number)
if track_id:
update_metadata(metadata, MetadataEntries.TRACK_ID, track_id)
if track_number:
update_metadata(metadata, MetadataEntries.TRACK_NUMBER, track_number)
return metadata
def create_metadata_from_track(track: Track | None, metadata: Metadata | None = None) -> Metadata:
match metadata:
case dict():
metadata: Metadata = metadata.copy()
case None | _:
metadata: Metadata = Metadata()
if not track:
return metadata
update_metadata_from_track(track, metadata)
return metadata
mpris_server-0.9.0/mpris_server/py.typed 0000664 0000000 0000000 00000000000 14533464522 0020455 0 ustar 00root root 0000000 0000000 mpris_server-0.9.0/mpris_server/server.py 0000664 0000000 0000000 00000007671 14533464522 0020663 0 ustar 00root root 0000000 0000000 from __future__ import annotations
import logging
from collections.abc import Iterable
from threading import Thread
from typing import Final
from weakref import finalize
from gi.repository import GLib
from pydbus import SessionBus, SystemBus
from pydbus.bus import Bus
from pydbus.publication import Publication
from .adapters import MprisAdapter
from .base import DBUS_PATH, Interface, NAME
from .enums import BusType
from .events import EventAdapter
from .interfaces.interface import MprisInterface
from .interfaces.player import Player
from .interfaces.playlists import Playlists
from .interfaces.root import Root
from .interfaces.tracklist import TrackList
from .mpris.compat import get_dbus_name
__all__ = [
'DEFAULT_BUS_TYPE', # for backwards compatibility
'BusType', # for backwards compatibility
'Server',
]
log = logging.getLogger(__name__)
DEFAULT_BUS_TYPE: Final[BusType] = BusType.SESSION
NOW: Final[int] = 0
class Server[A: MprisAdapter, E: EventAdapter, I: MprisInterface]:
name: str
adapter: A | None
events: E | None
root: Root
player: Player
playlists: Playlists
tracklist: TrackList
interfaces: tuple[I, ...]
dbus_name: str
_loop: GLib.MainLoop | None
_publication_token: Publication | None
_thread: Thread | None
def __init__(
self,
name: str = NAME,
adapter: A | None = None,
events: E | None = None,
*interfaces: I,
):
self.name = name
self.adapter = adapter
self.root = Root(self.name, self.adapter)
self.player = Player(self.name, self.adapter)
self.playlists = Playlists(self.name, self.adapter)
self.tracklist = TrackList(self.name, self.adapter)
self.interfaces = self.root, self.player, self.playlists, self.tracklist, *interfaces
self.dbus_name = get_dbus_name(self.name)
self._loop = None
self._publication_token = None
self._thread = None
self.set_event_adapter(events)
finalize(self, self.__del__)
def __del__(self):
self.quit()
def _get_dbus_paths(self) -> Iterable[tuple[str, I]]:
for interface in self.interfaces:
yield DBUS_PATH, interface
def _run_loop(self):
self._loop = GLib.MainLoop()
try:
self._loop.run()
finally:
self.quit_loop()
def set_event_adapter(self, events: E):
self.events = events
def publish(self, bus_type: BusType = BusType.DEFAULT):
log.debug(f'Connecting to D-Bus {bus_type} bus...')
bus: Bus
match bus_type:
case BusType.DEFAULT:
bus = SessionBus()
case BusType.SESSION:
bus = SessionBus()
case BusType.SYSTEM:
bus = SystemBus()
case _:
log.warning(f'Invalid bus "{bus_type}", using {BusType.DEFAULT}.')
bus = SessionBus()
log.debug(f'MPRIS server connecting to D-Bus {bus_type} bus.')
name = f'{Interface.Root}.{self.dbus_name}'
paths = self._get_dbus_paths()
self._publication_token = bus.publish(name, *paths)
log.info(f'Published {name} to D-Bus {bus_type} bus.')
def unpublish(self):
if self._publication_token:
log.debug('Unpublishing MPRIS interface.')
self._publication_token.unpublish()
self._publication_token = None
def loop(self, bus_type: BusType = BusType.DEFAULT, background: bool = False):
if not self._publication_token:
self.publish(bus_type)
if background:
log.debug("Entering D-Bus loop in background thread.")
self._thread = Thread(target=self._run_loop, name=self.name)
self._thread.start()
else:
log.debug("Entering D-Bus loop in foreground thread.")
self._run_loop()
def quit_loop(self):
try:
if self._loop:
log.debug('Quitting GLib loop.')
self._loop.quit()
self._loop = None
finally:
if self._thread:
log.debug("Joining background thread.")
self._thread.join(timeout=NOW)
self._thread = None
def quit(self):
log.debug('Unpublishing and quitting loop.')
self.unpublish()
self.quit_loop()
mpris_server-0.9.0/mpris_server/types.py 0000664 0000000 0000000 00000001110 14533464522 0020477 0 ustar 00root root 0000000 0000000 from __future__ import annotations
from typing import Final, GenericAlias, _GenericAlias, get_origin
ORIGIN: Final[str] = '__origin__'
type GenericAliases = GenericAlias | _GenericAlias
def is_type(obj: type) -> bool:
return isinstance(obj, type) or bool(get_origin(obj))
def is_generic(obj: type) -> bool:
return hasattr(obj, ORIGIN) or bool(get_origin(obj))
def get_type(obj: type) -> type | None:
if hasattr(obj, ORIGIN):
return getattr(obj, ORIGIN)
if origin := get_origin(obj):
return origin
if isinstance(obj, type):
return obj
return None
mpris_server-0.9.0/pyproject.toml 0000664 0000000 0000000 00000001020 14533464522 0017155 0 ustar 00root root 0000000 0000000 [tool]
[tool.poetry]
name = "mpris_server"
version = "0.9.0"
description = "Publish a MediaPlayer2 MPRIS device to D-Bus."
license = "LGPL-3.0"
homepage = "https://alexdelorenzo.dev"
authors = ["Alex DeLorenzo "]
readme = "README.md"
[tool.poetry.dependencies]
emoji = ">=2.8.0, <3.0.0"
pydbus = ">=0.6.0, <0.7.0"
pygobject = ">=3.34.0"
python = ">=3.11"
unidecode = ">=1.3.7, <2.0.0"
#[build-system]
#requires = [
# "setuptools >= 40.9.0",
# "wheel",
#]
#build-backend = "setuptools.build_meta"
mpris_server-0.9.0/requirements.txt 0000664 0000000 0000000 00000000235 14533464522 0017534 0 ustar 00root root 0000000 0000000 emoji>=2.8.0, <3.0.0
pydbus>=0.6.0, <0.7.0
PyGObject>=3.34.0
StrEnum>=0.4.15 # StrEnum preserves strings' case unlike enum.StrEnum
unidecode>=1.3.7, <2.0.0
mpris_server-0.9.0/setup.py 0000775 0000000 0000000 00000001777 14533464522 0016001 0 ustar 00root root 0000000 0000000 from pathlib import Path
from typing import Final
from setuptools import find_packages, setup
__author__ = "Alex DeLorenzo "
__version__: Final[str] = '0.9.0'
NAME = "mpris_server"
DESCRIPTION = "⏯️ Publish a MediaPlayer2 MPRIS device to D-Bus."
LICENSE = "License :: OSI Approved :: LGPL-3.0-only"
URL = "https://github.com/alexdelorenzo/mpris_server"
PKGS: list[str] = list({
f'{NAME}',
f'{NAME}.mpris',
f'{NAME}.interfaces',
*find_packages(),
})
REQS: list[str] = Path('requirements.txt') \
.read_text() \
.splitlines()
REQS: list[str] = [
req
for req in REQS
if not req.strip().startswith('#')
]
README: str = Path('README.md').read_text()
PYTHON_VERSION: str = '>=3.12'
setup(
name=NAME,
version=__version__,
description=DESCRIPTION,
long_description=README,
long_description_content_type="text/markdown",
url=URL,
author=__author__,
license=LICENSE,
packages=PKGS,
zip_safe=True,
install_requires=REQS,
python_requires=PYTHON_VERSION,
)