├── .gitignore ├── LICENSE.txt ├── README.md ├── contrib └── spotpris2.service ├── requirements.txt ├── setup.py └── spotpris2 ├── BusManager.py ├── MediaPlayer2.py ├── __init__.py ├── __main__.py ├── html └── success.html ├── mpris ├── org.mpris.MediaPlayer2.Player.xml └── org.mpris.MediaPlayer2.xml └── util.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .venv 3 | venv 4 | build 5 | dist 6 | SpotPRIS2.egg-info 7 | __pycache__ 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Adrian Freund 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SpotPRIS2 2 | ========= 3 | 4 | Control Spotify Connect devices using MPRIS2 5 | 6 | **** 7 | 8 | This software provides an MPRIS2 interface for Spotify Connect. It is more complete than the MPRIS2 interface built into 9 | the Spotify Linux client. 10 | In addition it can be used to control remote Spotify Connect devices (Like spotifyd running on a Raspberry Pi) from your 11 | PC. 12 | 13 | This software is still in development. Some things might not work as expected. 14 | 15 | Installation 16 | ------------ 17 | * Arch Linux: Install from AUR 18 | ```yay -S python-spotpris2``` 19 | * Other distributions: Install using pip 20 | ```pip install spotPRIS2``` 21 | 22 | Then just run `spotpris2`. 23 | 24 | Options 25 | ------- 26 | ``` 27 | -h, --help show this help message and exit 28 | -d DEVICE [DEVICE ...], --devices DEVICE [DEVICE ...] 29 | Only create interfaces for the listed devices 30 | -i DEVICE [DEVICE ...], --ignore DEVICE [DEVICE ...] 31 | Ignore the listed devices 32 | -a, --auto Automatically control the active device 33 | -l [{name,id}], --list [{name,id}] 34 | List available devices and exit 35 | ``` 36 | 37 | In normal mode SpotPRIS2 creates one MPRIS2 interface for each Spotify Connect device connected to your account. 38 | 39 | You can use `--devices` to only create interfaces for specified devices or `--ignore` to create interfaces for all but 40 | the specified devices. Devices can be specified either by their name or their ID. 41 | With `--auto` only one interface gets created. It will always control the currently active device. 42 | 43 | `--list` lists the names of all available devices. Use `--list=id` to list their IDs instead. 44 | 45 | Known problems 46 | -------------- 47 | 1. **Podcasts, Radios, etc. aren't supported** 48 | This is a limitation of the Spotify Web API. There's currently nothing I can do about it. 49 | 2. **The MPRIS2 interface only shows up when something is playing** 50 | If you are running SpotPRIS2 in auto mode this is intended. There is no way for SpotPRIS2 to know which device you 51 | want to control, so we don't offer any interface. You can use normal mode if you want to be able to start playback 52 | using MPRIS2. 53 | 54 | Systemd 55 | -------- 56 | To use SpotPRIS2 with systemd, the provided unit file (`contrib/spotpris2.service`) should be copied into `/usr/lib/systemd/user`. 57 | 58 | **** 59 | 60 | This project is not affiliated, associated, authorized, endorsed by, or in any way officially connected with Spotify AB, 61 | or any of its subsidiaries or its affiliates. 62 | 63 | 64 | -------------------------------------------------------------------------------- /contrib/spotpris2.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Control Spotify Connect devices using MPRIS2 3 | Documentation=https://github.com/freundTech/SpotPRIS2 4 | Requires=dbus.socket 5 | After=dbus.socket 6 | Wants=network-online.target 7 | After=network-online.target 8 | 9 | [Service] 10 | ExecStart=/usr/bin/spotpris2 11 | Restart=always 12 | RestartSec=12 13 | 14 | [Install] 15 | WantedBy=default.target 16 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | --index-url https://pypi.python.org/simple/ 2 | 3 | -e . 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open("README.md", "r") as f: 4 | long_description = f.read() 5 | 6 | setup(name="SpotPRIS2", 7 | version='0.4.1', 8 | author="Adrian Freund", 9 | author_email="adrian@freund.io", 10 | url="https://github.com/freundTech/SpotPRIS2", 11 | description="MPRIS2 interface for Spotify Connect", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | packages=['spotpris2'], 15 | package_dir={'spotpris2': "spotpris2"}, 16 | package_data={'spotpris2': ['mpris/*.xml', 'html/*.html']}, 17 | install_requires=[ 18 | "PyGObject", 19 | "pydbus", 20 | "spotipy>=2.8", 21 | "appdirs", 22 | ], 23 | entry_points={ 24 | 'console_scripts': ["spotpris2=spotpris2.__main__:main"] 25 | }, 26 | classifiers=[ 27 | "Development Status :: 3 - Alpha", 28 | "Environment :: No Input/Output (Daemon)", 29 | "Intended Audience :: End Users/Desktop", 30 | "License :: OSI Approved :: MIT License", 31 | "Operating System :: POSIX :: Linux", 32 | "Programming Language :: Python :: 3 :: Only", 33 | "Topic :: Multimedia :: Sound/Audio", 34 | ], 35 | python_requires='>=3.6', 36 | ) 37 | -------------------------------------------------------------------------------- /spotpris2/BusManager.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from typing import List 3 | 4 | from pydbus import SessionBus 5 | from pydbus.bus import Bus 6 | from spotipy import Spotify 7 | 8 | from .util import new_session_bus, create_playback_state 9 | from . import MediaPlayer2 10 | 11 | 12 | class BusManager(metaclass=ABCMeta): 13 | def __init__(self, spotify: Spotify, app_name: str): 14 | self.spotify = spotify 15 | self.app_name = app_name 16 | 17 | @abstractmethod 18 | def main_loop(self): 19 | pass 20 | 21 | 22 | class SingleBusManager(BusManager): 23 | def __init__(self, spotify: Spotify, bus: Bus = None, app_name: str = "spotpris"): 24 | super().__init__(spotify, app_name) 25 | self.registration = None 26 | if bus is None: 27 | self.bus = SessionBus() # Use built in method to get bus singleton 28 | self.bus.request_name(f"org.mpris.MediaPlayer2.{app_name}") 29 | else: 30 | self.bus = bus 31 | self.player = None 32 | 33 | def _register(self, player): 34 | self.player = player 35 | self.registration = self.bus.register_object("/org/mpris/MediaPlayer2", player, None) 36 | 37 | def main_loop(self): 38 | current_playback = self.spotify.current_playback() 39 | if current_playback is None: 40 | if self.registration is not None: 41 | self.registration.unregister() 42 | self.registration = None 43 | else: 44 | if self.registration is None: 45 | if self.player is None: 46 | self._register(MediaPlayer2(self.spotify, current_playback)) 47 | self.player.event_loop(current_playback) 48 | 49 | 50 | class MultiBusManager(BusManager): 51 | def __init__(self, spotify: Spotify, allowed_devices: List[str] = None, ignored_devices: List[str] = None, 52 | app_name: str = "spotpris"): 53 | super().__init__(spotify, app_name) 54 | self.allowed_devices = allowed_devices 55 | self.ignored_devices = ignored_devices 56 | self.current_devices = {} 57 | 58 | def main_loop(self): 59 | devices = self.spotify.devices()["devices"] 60 | devices = {d["id"]: d for d in devices} 61 | current_playback = self.spotify.current_playback() 62 | for device_id in devices: 63 | if device_id not in self.current_devices and self._is_device_allowed(devices[device_id]): 64 | self._create_device(device_id, create_playback_state(current_playback, devices[device_id])) 65 | 66 | for device_id in list(self.current_devices.keys()): 67 | if device_id not in devices: 68 | self._remove_device(device_id) 69 | else: 70 | self.current_devices[device_id].player.event_loop( 71 | create_playback_state(current_playback, device=devices[device_id])) 72 | 73 | def _create_device(self, device_id, current_playback): 74 | bus = new_session_bus() 75 | player = MediaPlayer2(self.spotify, current_playback, device_id=device_id) 76 | publication = self._publish(bus, player, bus_postfix=f"device{device_id}") 77 | self.current_devices[device_id] = self.PlayerInfo(player, bus, publication) 78 | 79 | def _publish(self, bus, player, bus_postfix=None): 80 | bus_name = f"org.mpris.MediaPlayer2.{self.app_name}" 81 | if bus_postfix is not None: 82 | bus_name = f"{bus_name}.{bus_postfix}" 83 | return bus.publish(bus_name, ("/org/mpris/MediaPlayer2", player)) 84 | 85 | def _remove_device(self, device_id): 86 | player_info = self.current_devices[device_id] 87 | player_info.publication.unpublish() 88 | player_info.bus.con.close() 89 | del self.current_devices[device_id] 90 | 91 | def _is_device_allowed(self, device): 92 | if self.allowed_devices is not None: 93 | return device["name"] in self.allowed_devices or device["id"] in self.allowed_devices 94 | elif self.ignored_devices is not None: 95 | return device["name"] not in self.ignored_devices and device["id"] not in self.ignored_devices 96 | else: 97 | return True 98 | 99 | class PlayerInfo: 100 | def __init__(self, player, bus, publication): 101 | self.player = player 102 | self.bus = bus 103 | self.publication = publication 104 | -------------------------------------------------------------------------------- /spotpris2/MediaPlayer2.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Any, List 2 | 3 | from pydbus.generic import signal 4 | from pydbus import Variant 5 | import re 6 | 7 | from spotipy import Spotify 8 | 9 | from .util import ms_to_us, get_recursive_path, float_to_percent, percent_to_float, track_id_to_path, us_to_ms, \ 10 | time_millis 11 | 12 | 13 | class MediaPlayer2: 14 | propertyMap = { 15 | ("repeat_state",): "LoopStatus", 16 | ("shuffle_state",): "Shuffle", 17 | ("is_playing",): "PlaybackStatus", 18 | ("device", "volume_percent"): "Volume", 19 | ("item", "id"): "Metadata" 20 | } 21 | uriFormats = [ 22 | re.compile("spotify:(?P[a-z]*):(?P[a-zA-Z0-9]*)"), 23 | re.compile("https?://open.spotify.com/(?P[a-z]*)/(?P[a-zA-Z0-9]*)"), 24 | ] 25 | 26 | def __init__(self, spotify: Spotify, current_playback: Dict[str, Any], device_id: str = None): 27 | self.spotify = spotify 28 | self.device_id = device_id 29 | self.request_time = time_millis() 30 | self.current_playback = current_playback 31 | self.position_offset = 0 32 | 33 | def Raise(self): 34 | pass 35 | 36 | def Quit(self): 37 | pass 38 | 39 | @property 40 | def CanQuit(self) -> bool: 41 | return False 42 | 43 | @property 44 | def CanRaise(self) -> bool: 45 | return False 46 | 47 | @property 48 | def HasTrackList(self) -> bool: 49 | return False # Maybe in the future 50 | 51 | @property 52 | def Identity(self) -> str: 53 | return self.current_playback["device"]["name"] 54 | 55 | @property 56 | def SupportedUriSchemes(self) -> List[str]: 57 | return ["spotify"] 58 | 59 | @property 60 | def SupportedMimeTypes(self) -> List[str]: 61 | return [] 62 | 63 | def Next(self): 64 | self.spotify.next_track(device_id=self.device_id) 65 | 66 | def Previous(self): 67 | self.spotify.previous_track(device_id=self.device_id) 68 | 69 | def Pause(self): 70 | self.spotify.pause_playback(device_id=self.device_id) 71 | 72 | def PlayPause(self): 73 | if self.current_playback["is_playing"]: 74 | self.Pause() 75 | else: 76 | self.Play() 77 | 78 | def Stop(self): 79 | self.spotify.pause_playback(device_id=self.device_id) 80 | 81 | def Play(self): 82 | if self.current_playback["device"] is not None and self.current_playback["device"]["is_active"]: 83 | self.spotify.start_playback(device_id=self.device_id) 84 | else: 85 | if self.device_id is None: 86 | device_id = self.spotify.devices()["devices"][0]["id"] 87 | else: 88 | device_id = self.device_id 89 | self.spotify.transfer_playback(device_id=device_id, force_play=True) 90 | 91 | def Seek(self, offset: int): 92 | if self.current_playback["item"] is None: 93 | return 94 | position = self.current_playback["progress_ms"] 95 | new_position = max(position + us_to_ms(offset), 0) 96 | self.spotify.seek_track(new_position, device_id=self.device_id) 97 | 98 | def SetPosition(self, track_id: str, position: int): 99 | if self.current_playback["item"] is None: 100 | return 101 | if track_id != track_id_to_path(self.current_playback["item"]["uri"]): 102 | print("Stale set position request. Ignoring.") 103 | return 104 | if position < 0 or position > ms_to_us(self.current_playback["item"]["duration_ms"]): 105 | return 106 | self.spotify.seek_track(us_to_ms(position), device_id=self.device_id) 107 | 108 | def OpenUri(self, uri: str): 109 | uri = uri.strip() 110 | match = None 111 | for format_ in self.uriFormats: 112 | match = format_.fullmatch(uri) 113 | if match: 114 | break 115 | 116 | if not match: 117 | print("Tried to open invalid uri. Ignoring.") 118 | 119 | type_ = match.group('type') 120 | id_ = match.group('id') 121 | 122 | new_uri = f"spotify:{type_}:{id_}" 123 | 124 | if type_ in ['album', 'artist', 'playlist', 'show']: 125 | self.spotify.start_playback(context_uri=new_uri, device_id=self.device_id) 126 | else: 127 | self.spotify.start_playback(uris=[new_uri], device_id=self.device_id) 128 | 129 | Seeked = signal() 130 | 131 | @property 132 | def PlaybackStatus(self) -> str: 133 | if self.current_playback["item"] is None: 134 | return "Stopped" 135 | elif self.current_playback["is_playing"]: 136 | return "Playing" 137 | else: 138 | return "Paused" 139 | 140 | @property 141 | def LoopStatus(self) -> str: 142 | status = self.current_playback["repeat_state"] 143 | if status == "off": 144 | return "None" 145 | elif status == "track": 146 | return "Track" 147 | elif status == "context": 148 | return "Playlist" 149 | else: 150 | raise Exception(f"Unhandled case: Repeat state {status} returned by spotify api") 151 | 152 | @LoopStatus.setter 153 | def LoopStatus(self, loopstatus: str): 154 | if loopstatus == "None": 155 | status = "off" 156 | elif loopstatus == "Track": 157 | status = "track" 158 | elif loopstatus == "Playlist": 159 | status = "context" 160 | else: 161 | print("Gotten invalid loop status from MPRIS2. Ignoring") 162 | return 163 | 164 | self.spotify.repeat(status, device_id=self.device_id) 165 | 166 | @property 167 | def Rate(self) -> float: 168 | return 1.0 169 | 170 | @Rate.setter 171 | def Rate(self, rate: float): 172 | if rate != 1.0: 173 | print("Gotten invalid rate from MPRIS2. Ignoring") 174 | 175 | @property 176 | def Shuffle(self) -> bool: 177 | return self.current_playback["shuffle_state"] 178 | 179 | @Shuffle.setter 180 | def Shuffle(self, shuffle: bool): 181 | self.spotify.shuffle(shuffle, device_id=self.device_id) 182 | 183 | @property 184 | def Metadata(self) -> Dict[str, Variant]: 185 | if self.current_playback["item"] is None: 186 | return {"mpris:trackid": Variant('o', "/org/mpris/MediaPlayer2/TrackList/NoTrack")} 187 | 188 | item = self.current_playback["item"] 189 | 190 | metadata = {"mpris:trackid": Variant('o', track_id_to_path(item["uri"])), 191 | "mpris:length": Variant('x', ms_to_us(item["duration_ms"])), 192 | "mpris:artUrl": Variant('s', item["album"]["images"][0]["url"]), 193 | "xesam:album": Variant('s', item["album"]["name"]), 194 | "xesam:albumArtist": Variant('as', [artist["name"] for artist in item["album"]["artists"]]), 195 | "xesam:artist": Variant('as', [artist["name"] for artist in item["artists"]]), 196 | "xesam:contentCreated": Variant('s', item["album"]["release_date"]), 197 | "xesam:discNumber": Variant('i', item["disc_number"]), 198 | "xesam:title": Variant('s', item["name"]), 199 | "xesam:trackNumber": Variant('i', item["track_number"]), 200 | "xesam:url": Variant('s', item["external_urls"]["spotify"]), 201 | } 202 | 203 | return metadata 204 | 205 | @property 206 | def Volume(self) -> float: 207 | if self.current_playback["device"] is None: 208 | return 1.0 209 | return percent_to_float(self.current_playback["device"]["volume_percent"]) 210 | 211 | @Volume.setter 212 | def Volume(self, volume: float): 213 | volume = max(min(volume, 1.0), 0.0) 214 | self.spotify.volume(float_to_percent(volume), device_id=self.device_id) 215 | 216 | @property 217 | def Position(self) -> int: 218 | if self.current_playback is None: 219 | return 0 220 | return ms_to_us(self.current_playback["progress_ms"]) 221 | 222 | @property 223 | def MinimumRate(self) -> float: 224 | return 1.0 225 | 226 | @property 227 | def MaximumRate(self) -> float: 228 | return 1.0 229 | 230 | @property 231 | def CanGoNext(self) -> bool: 232 | return True 233 | 234 | @property 235 | def CanGoPrevious(self) -> bool: 236 | return True 237 | 238 | @property 239 | def CanPlay(self) -> bool: 240 | return True 241 | 242 | @property 243 | def CanPause(self) -> bool: 244 | return True 245 | 246 | @property 247 | def CanSeek(self) -> bool: 248 | return True 249 | 250 | @property 251 | def CanControl(self) -> bool: 252 | return True 253 | 254 | PropertiesChanged = signal() 255 | 256 | def event_loop(self, current_playback: Dict[str, Any]): 257 | old_playback = self.current_playback 258 | self.current_playback = current_playback 259 | old_request_time = self.request_time 260 | self.request_time = time_millis() 261 | changed = {} 262 | 263 | for path, property_ in self.propertyMap.items(): 264 | if get_recursive_path(old_playback, path) != get_recursive_path(self.current_playback, path): 265 | changed[property_] = getattr(self, property_) 266 | 267 | # emit signal if song progress is out of sync with time 268 | progress = self.current_playback["progress_ms"] - old_playback["progress_ms"] 269 | expected = self.request_time - old_request_time if self.current_playback["is_playing"] else 0 270 | if "Metadata" in changed or "PlaybackStatus" in changed: 271 | self.position_offset = 0 272 | else: 273 | self.position_offset += progress - expected 274 | 275 | if abs(self.position_offset) > 100: 276 | self.position_offset = 0 277 | self.Seeked.emit(ms_to_us(self.current_playback["progress_ms"])) 278 | 279 | if changed: 280 | self.PropertiesChanged.emit("org.mpris.MediaPlayer2.Player", changed, []) 281 | 282 | if self.current_playback["device"]["name"] != old_playback["device"]["name"]: 283 | self.PropertiesChanged.emit("org.mpris.MediaPlayer2", {"Identity": self.Identity}, []) 284 | -------------------------------------------------------------------------------- /spotpris2/__init__.py: -------------------------------------------------------------------------------- 1 | from .MediaPlayer2 import MediaPlayer2 2 | from .BusManager import BusManager, SingleBusManager, MultiBusManager 3 | 4 | __all__ = ['MediaPlayer2', 'BusManager', 'SingleBusManager', 'MultiBusManager'] 5 | -------------------------------------------------------------------------------- /spotpris2/__main__.py: -------------------------------------------------------------------------------- 1 | from gi.repository import GLib 2 | from pydbus import SessionBus 3 | from spotipy import SpotifyOAuth, Spotify 4 | from appdirs import AppDirs 5 | from configparser import ConfigParser 6 | from http.server import HTTPServer, BaseHTTPRequestHandler 7 | 8 | from .BusManager import SingleBusManager, MultiBusManager 9 | from . import MediaPlayer2 10 | import pkg_resources 11 | import webbrowser 12 | import argparse 13 | 14 | ifaces = ["org.mpris.MediaPlayer2", 15 | "org.mpris.MediaPlayer2.Player"] # , "org.mpris.MediaPlayer2.Playlists", "org.mpris.MediaPlayer2.TrackList"] 16 | dirs = AppDirs("spotpris2", "freundTech") 17 | scope = "user-modify-playback-state,user-read-playback-state,user-read-currently-playing" 18 | 19 | 20 | def main(): 21 | parser = argparse.ArgumentParser(description="Control Spotify Connect devices using MPRIS2") 22 | parser.add_argument('-d', '--devices', nargs='+', metavar="DEVICE", 23 | help="Only create interfaces for the listed devices") 24 | parser.add_argument('-i', '--ignore', nargs='+', metavar="DEVICE", help="Ignore the listed devices") 25 | parser.add_argument('-a', '--auto', action="store_true", help="Automatically control the active device") 26 | parser.add_argument('-l', '--list', nargs='?', choices=["name", "id"], const="name", 27 | help="List available devices and exit") 28 | parser.add_argument('-s', '--steal-bus', action="store_true", help="Steal the dbus bus name from spotify to prevent " 29 | "it from also offering an MPRIS2 interface. If --auto is used use the spotify bus name as own " 30 | "bus name (experimental)") 31 | args = parser.parse_args() 32 | 33 | MediaPlayer2.dbus = [pkg_resources.resource_string(__name__, f"mpris/{iface}.xml").decode('utf-8') for iface in 34 | ifaces] 35 | 36 | loop = GLib.MainLoop() 37 | 38 | oauth = authenticate() 39 | sp = Spotify(oauth_manager=oauth) 40 | 41 | if args.list: 42 | devices = sp.devices() 43 | for devices in devices["devices"]: 44 | print(devices[args.list]) 45 | return 46 | 47 | exclusive_count = 0 48 | for arg in [args.devices, args.ignore, args.auto]: 49 | if arg: 50 | exclusive_count += 1 51 | if exclusive_count >= 2: 52 | parser.error("Only one of --devices, --ignore and --auto can be used at the same time") 53 | return 54 | 55 | if args.steal_bus: 56 | bus = SessionBus() 57 | try: 58 | # This sets the bus name for the SessionBus singleton which is also used by SingleBusManager 59 | bus.request_name("org.mpris.MediaPlayer2.spotify", allow_replacement=False, replace=True) 60 | except RuntimeError: 61 | print("Failed to steal spotify bus name. You need to start spotPRIS2 before spotify") 62 | exit(1) 63 | 64 | if not args.auto: 65 | manager = MultiBusManager(sp, args.devices, args.ignore) 66 | else: 67 | if args.steal_bus: 68 | manager = SingleBusManager(sp, bus=bus) 69 | else: 70 | manager = SingleBusManager(sp) 71 | 72 | def timeout_handler(): 73 | try: 74 | manager.main_loop() 75 | except Exception as e: 76 | print(e) 77 | finally: 78 | return True 79 | 80 | GLib.timeout_add_seconds(1, timeout_handler) 81 | 82 | try: 83 | loop.run() 84 | except KeyboardInterrupt: 85 | pass 86 | 87 | 88 | def authenticate(): 89 | class RequestHandler(BaseHTTPRequestHandler): 90 | callbackUri = None 91 | 92 | def do_GET(self): 93 | self.send_response(200, "OK") 94 | self.end_headers() 95 | 96 | self.wfile.write(pkg_resources.resource_string(__name__, "html/success.html")) 97 | RequestHandler.callbackUri = self.path 98 | 99 | config = get_config() 100 | 101 | oauth = SpotifyOAuth( 102 | client_id=config["client_id"], 103 | client_secret=config["client_secret"], 104 | redirect_uri="http://localhost:8000", 105 | scope=scope, 106 | cache_path=dirs.user_cache_dir, 107 | ) 108 | 109 | token_info = oauth.get_cached_token() 110 | 111 | if not token_info: 112 | url = oauth.get_authorize_url() 113 | webbrowser.open(url) 114 | 115 | server = HTTPServer(('', 8000), RequestHandler) 116 | server.handle_request() 117 | 118 | code = oauth.parse_response_code(RequestHandler.callbackUri) 119 | oauth.get_access_token(code, as_dict=False) 120 | return oauth 121 | 122 | 123 | def get_config(): 124 | config = ConfigParser() 125 | config.read(f"{dirs.user_config_dir}.cfg") 126 | if "spotpris2" not in config: 127 | config["spotpris2"] = {} 128 | section = config["spotpris2"] 129 | if section.get("client_id") is None or section.get("client_secret") is None: 130 | print("To use this software you need to provide your own spotify developer credentials. Go to " 131 | "https://developer.spotify.com/dashboard/applications, create a new client id and add " 132 | "http://localhost:8000 to the redirect URIs.") 133 | section["client_id"] = input("Enter client id: ") 134 | section["client_secret"] = input("Enter client secret: ") 135 | with open(f"{dirs.user_config_dir}.cfg", 'w+') as f: 136 | config.write(f) 137 | return config["spotpris2"] 138 | 139 | 140 | if __name__ == '__main__': 141 | main() 142 | -------------------------------------------------------------------------------- /spotpris2/html/success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Successfully signed in to SpotPRIS2 6 | 7 | 8 |

Success

9 | You successfully signed in to SpotPRIS2 10 | 11 | -------------------------------------------------------------------------------- /spotpris2/mpris/org.mpris.MediaPlayer2.Player.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /spotpris2/mpris/org.mpris.MediaPlayer2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /spotpris2/util.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar, List, Any, Dict 2 | 3 | from time import time 4 | 5 | from gi.repository import Gio 6 | from pydbus import connect 7 | from pydbus.bus import Bus 8 | 9 | 10 | # Creates a new bus. pydbus SessionBus() returns a singleton, but we don't want that 11 | def new_session_bus() -> Bus: 12 | return new_bus(Gio.BusType.SESSION) 13 | 14 | 15 | def new_bus(type_) -> Bus: 16 | return connect(Gio.dbus_address_get_for_bus_sync(type_)) 17 | 18 | 19 | def time_millis() -> int: 20 | return int(time() * 1000) 21 | 22 | 23 | def track_id_to_path(track: str) -> str: 24 | return '/' + track.replace(':', '/') 25 | 26 | 27 | def ms_to_us(ms: int) -> int: 28 | return ms * 1000 29 | 30 | 31 | def us_to_ms(us: int) -> int: 32 | return us // 1000 33 | 34 | 35 | def percent_to_float(percent: int) -> float: 36 | return percent / 100 37 | 38 | 39 | def float_to_percent(n: float) -> int: 40 | return int(n * 100) 41 | 42 | 43 | T = TypeVar('T') 44 | 45 | 46 | def get_recursive_path(data: Dict[T, Any], path: List[T]) -> Any: 47 | try: 48 | for segment in path: 49 | data = data[segment] 50 | except (KeyError, TypeError): 51 | return None 52 | 53 | return data 54 | 55 | 56 | def create_playback_state(current_playback: Dict[str, Any], device: Dict[str, Any]) -> Dict[str, Any]: 57 | if current_playback is None: 58 | return { 59 | 'device': device, 60 | 'item': None, 61 | 'context': None, 62 | 'is_playing': False, 63 | 'progress_ms': 0, 64 | # Not ideal, but no way to get the real values while nothing is playing 65 | 'repeat_state': "off", 66 | 'shuffle_state': False, 67 | } 68 | elif current_playback["device"]["id"] == device["id"]: 69 | return current_playback 70 | else: 71 | current_playback = current_playback.copy() 72 | current_playback["is_playing"] = False 73 | current_playback["device"] = device 74 | return current_playback 75 | --------------------------------------------------------------------------------