pax_global_header00006660000000000000000000000064145334645220014522gustar00rootroot0000000000000052 comment=129118d9fc13b181e16d809738af74a8046fc62c mpris_server-0.9.0/000077500000000000000000000000001453346452200142505ustar00rootroot00000000000000mpris_server-0.9.0/.github/000077500000000000000000000000001453346452200156105ustar00rootroot00000000000000mpris_server-0.9.0/.github/FUNDING.yml000066400000000000000000000001071453346452200174230ustar00rootroot00000000000000# These are supported funding model platforms github: [alexdelorenzo] mpris_server-0.9.0/LICENSE000066400000000000000000000167431453346452200152700ustar00rootroot00000000000000 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.in000066400000000000000000000000531453346452200160040ustar00rootroot00000000000000include README.md requirements.txt LICENSE mpris_server-0.9.0/NOTICE000066400000000000000000000006101453346452200151510ustar00rootroot00000000000000All 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.md000066400000000000000000000121011453346452200155220ustar00rootroot00000000000000# ▶️ 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? Buy Me A Coffee ## 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/000077500000000000000000000000001453346452200155525ustar00rootroot00000000000000mpris_server-0.9.0/assets/apache-2.0.txt000066400000000000000000000261361453346452200200410ustar00rootroot00000000000000 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/000077500000000000000000000000001453346452200167705ustar00rootroot00000000000000mpris_server-0.9.0/mpris_server/__init__.py000066400000000000000000000004761453346452200211100ustar00rootroot00000000000000from __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.py000066400000000000000000000112211453346452200211420ustar00rootroot00000000000000from __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.py000066400000000000000000000161031453346452200202550ustar00rootroot00000000000000from __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.py000066400000000000000000000041401453346452200204700ustar00rootroot00000000000000from __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.py000066400000000000000000000104151453346452200206470ustar00rootroot00000000000000from __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/000077500000000000000000000000001453346452200211135ustar00rootroot00000000000000mpris_server-0.9.0/mpris_server/interfaces/__init__.py000066400000000000000000000006341453346452200232270ustar00rootroot00000000000000from .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.py000066400000000000000000000017631453346452200234340ustar00rootroot00000000000000from __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.py000066400000000000000000000254161453346452200227710ustar00rootroot00000000000000from __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.py000066400000000000000000000044611453346452200235160ustar00rootroot00000000000000from __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.py000066400000000000000000000056511453346452200224570ustar00rootroot00000000000000from __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.py000066400000000000000000000060521453346452200234700ustar00rootroot00000000000000from __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/000077500000000000000000000000001453346452200201225ustar00rootroot00000000000000mpris_server-0.9.0/mpris_server/mpris/__init__.py000066400000000000000000000013521453346452200222340ustar00rootroot00000000000000from . 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.py000066400000000000000000000042711453346452200217630ustar00rootroot00000000000000# 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.py000066400000000000000000000310531453346452200222560ustar00rootroot00000000000000from __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.typed000066400000000000000000000000001453346452200204550ustar00rootroot00000000000000mpris_server-0.9.0/mpris_server/server.py000066400000000000000000000076711453346452200206630ustar00rootroot00000000000000from __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.py000066400000000000000000000011101453346452200204770ustar00rootroot00000000000000from __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.toml000066400000000000000000000010201453346452200171550ustar00rootroot00000000000000[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.txt000066400000000000000000000002351453346452200175340ustar00rootroot00000000000000emoji>=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.py000077500000000000000000000017771453346452200160010ustar00rootroot00000000000000from 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, )