forked from platypush/platypush
[#297] Removed music.spotify
backend.
1. I no longer I use a Spotify account (I switched to Tidal after Spotify deprecated libspotify), and I wouldn't like to create one just to test this integration. 2. After a couple of years, the libspotify open fork (Librespot) seems to be still in an unstable stage and it's already been discontinued once - I would avoid rebuilding the integration against a dependency that may change a lot in the near future.
This commit is contained in:
parent
73cc47b964
commit
d2ec93b798
7 changed files with 4 additions and 383 deletions
|
@ -1,329 +0,0 @@
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
|
|
||||||
from platypush.backend import Backend
|
|
||||||
from platypush.common.spotify import SpotifyMixin
|
|
||||||
from platypush.config import Config
|
|
||||||
from platypush.message.event.music import (
|
|
||||||
MusicPlayEvent,
|
|
||||||
MusicPauseEvent,
|
|
||||||
MusicStopEvent,
|
|
||||||
NewPlayingTrackEvent,
|
|
||||||
SeekChangeEvent,
|
|
||||||
VolumeChangeEvent,
|
|
||||||
)
|
|
||||||
from platypush.utils import get_redis
|
|
||||||
|
|
||||||
from .event import status_queue
|
|
||||||
|
|
||||||
|
|
||||||
class MusicSpotifyBackend(Backend, SpotifyMixin):
|
|
||||||
"""
|
|
||||||
This backend uses `librespot <https://github.com/librespot-org/librespot>`_ to turn Platypush into an audio client
|
|
||||||
compatible with Spotify Connect and discoverable by a device running a Spotify client or app. It can be used to
|
|
||||||
stream Spotify through the Platypush host. After the backend has started, you should see a new entry in the
|
|
||||||
Spotify Connect devices list in your app.
|
|
||||||
|
|
||||||
Requires:
|
|
||||||
|
|
||||||
* **librespot**. Consult the `README <https://github.com/librespot-org/librespot>`_ for instructions.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
librespot_path: str = 'librespot',
|
|
||||||
device_name: Optional[str] = None,
|
|
||||||
device_type: str = 'speaker',
|
|
||||||
audio_backend: str = 'alsa',
|
|
||||||
audio_device: Optional[str] = None,
|
|
||||||
mixer: str = 'softvol',
|
|
||||||
mixer_name: str = 'PCM',
|
|
||||||
mixer_card: str = 'default',
|
|
||||||
mixer_index: int = 0,
|
|
||||||
volume: int = 100,
|
|
||||||
volume_ctrl: str = 'linear',
|
|
||||||
bitrate: int = 160,
|
|
||||||
autoplay: bool = False,
|
|
||||||
disable_gapless: bool = False,
|
|
||||||
username: Optional[str] = None,
|
|
||||||
password: Optional[str] = None,
|
|
||||||
client_id: Optional[str] = None,
|
|
||||||
client_secret: Optional[str] = None,
|
|
||||||
proxy: Optional[str] = None,
|
|
||||||
ap_port: Optional[int] = None,
|
|
||||||
disable_discovery: bool = False,
|
|
||||||
cache_dir: Optional[str] = None,
|
|
||||||
system_cache_dir: Optional[str] = None,
|
|
||||||
disable_audio_cache=False,
|
|
||||||
enable_volume_normalization: bool = False,
|
|
||||||
normalization_method: str = 'dynamic',
|
|
||||||
normalization_pre_gain: Optional[float] = None,
|
|
||||||
normalization_threshold: float = -1.0,
|
|
||||||
normalization_attack: int = 5,
|
|
||||||
normalization_release: int = 100,
|
|
||||||
normalization_knee: float = 1.0,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
:param librespot_path: Librespot path/executable name (default: ``librespot``).
|
|
||||||
:param device_name: Device name (default: same as configured Platypush ``device_id`` or hostname).
|
|
||||||
:param device_type: Device type to be shown in the icon. Available types:
|
|
||||||
``unknown``, ``computer``, ``tablet``, ``smartphone``, ``speaker``, ``tv``,
|
|
||||||
``avr`` (Audio/Video Receiver), ``stb`` (Set-Top Box), or ``audiodongle`` (default: ``speaker``).
|
|
||||||
:param audio_backend: Audio backend to be used. Supported values:
|
|
||||||
``alsa``, ``portaudio``, ``pulseaudio``, ``jackaudio``, ``gstreamer``, ``rodio``, ``rodiojack``,
|
|
||||||
``sdl`` (default: ``alsa``).
|
|
||||||
:param audio_device: Output audio device. Type ``librespot --device ?`` to get a list of the available devices.
|
|
||||||
:param mixer: Mixer to be used to control the volume. Supported values: ``alsa`` or ``softvol``
|
|
||||||
(default: ``softvol``).
|
|
||||||
:param mixer_name: Mixer name if using the ALSA mixer. Supported values: ``PCM`` or ``Master``
|
|
||||||
(default: ``PCM``).
|
|
||||||
:param mixer_card: ALSA mixer output card, as reported by ``aplay -l`` (default: ``default``).
|
|
||||||
:param mixer_index: ALSA card index, as reported by ``aplay -l`` (default: 0).
|
|
||||||
:param volume: Initial volume, as an integer between 0 and 100 if ``volume_ctrl=linear`` or in dB if
|
|
||||||
``volume_ctrl=logarithmic``.
|
|
||||||
:param volume_ctrl: Volume control scale. Supported values: ``linear`` and ``logarithmic``
|
|
||||||
(default: ``linear``).
|
|
||||||
:param bitrate: Audio bitrate. Choose 320 for maximum quality (default: 160).
|
|
||||||
:param autoplay: Play similar tracks when the queue ends (default: False).
|
|
||||||
:param disable_gapless: Disable gapless audio (default: False).
|
|
||||||
:param username: Spotify user/device username (used if you want to enable Spotify Connect remotely).
|
|
||||||
:param password: Spotify user/device password (used if you want to enable Spotify Connect remotely).
|
|
||||||
:param client_id: Spotify client ID, required if you want to retrieve track and album info through the
|
|
||||||
Spotify Web API. You can generate one by creating a Spotify app `here <https://developer.spotify.com/>`.
|
|
||||||
:param client_secret: Spotify client secret, required if you want to retrieve track and album info
|
|
||||||
through the Spotify Web API. You can generate one by creating a Spotify app
|
|
||||||
`here <https://developer.spotify.com/>`.
|
|
||||||
:param proxy: Optional HTTP proxy configuration.
|
|
||||||
:param ap_port: Spotify AP port to be used (default: default ports, i.e. 80, 443 and 4070).
|
|
||||||
:param disable_discovery: Disable discovery mode.
|
|
||||||
:param cache_dir: Data files cache directory.
|
|
||||||
:param system_cache_dir: System cache directory - it includes audio settings and credentials.
|
|
||||||
:param disable_audio_cache: Disable audio caching (default: False).
|
|
||||||
:param enable_volume_normalization: Play all the tracks at about the same volume (default: False).
|
|
||||||
:param normalization_method: If ``enable_volume_normalization=True``, this setting specifies the volume
|
|
||||||
normalization method. Supported values: ``basic``, ``dynamic`` (default: ``dynamic``).
|
|
||||||
:param normalization_pre_gain: Pre-gain applied to volume normalization if ``enable_volume_normalization=True``,
|
|
||||||
expressed in dB.
|
|
||||||
:param normalization_threshold: If ``enable_volume_normalization=True``, this setting specifies the
|
|
||||||
normalization threshold (in dBFS) to prevent audio clipping (default: -1.0).
|
|
||||||
:param normalization_attack: If ``enable_volume_normalization=True``, this setting specifies the attack time
|
|
||||||
(in ms) during which the dynamic limiter is reducing the gain (default: 5).
|
|
||||||
:param normalization_release: If ``enable_volume_normalization=True``, this setting specifies the release time
|
|
||||||
(in ms) for the dynamic limiter to restore the gain (default: 100).
|
|
||||||
:param normalization_knee: Knee steepness of the dynamic limiter (default: 1.0).
|
|
||||||
"""
|
|
||||||
Backend.__init__(self, **kwargs)
|
|
||||||
SpotifyMixin.__init__(self, client_id=client_id, client_secret=client_secret)
|
|
||||||
self.device_name = device_name or Config.get('device_id')
|
|
||||||
self._librespot_args = [
|
|
||||||
librespot_path,
|
|
||||||
'--name',
|
|
||||||
self.device_name,
|
|
||||||
'--backend',
|
|
||||||
audio_backend,
|
|
||||||
'--device-type',
|
|
||||||
device_type,
|
|
||||||
'--mixer',
|
|
||||||
mixer,
|
|
||||||
'--alsa-mixer-control',
|
|
||||||
mixer_name,
|
|
||||||
'--initial-volume',
|
|
||||||
str(volume),
|
|
||||||
'--volume-ctrl',
|
|
||||||
volume_ctrl,
|
|
||||||
'--bitrate',
|
|
||||||
str(bitrate),
|
|
||||||
'--emit-sink-events',
|
|
||||||
'--onevent',
|
|
||||||
'python -m platypush.backend.music.spotify.event',
|
|
||||||
]
|
|
||||||
|
|
||||||
if audio_device:
|
|
||||||
self._librespot_args += ['--alsa-mixer-device', audio_device]
|
|
||||||
else:
|
|
||||||
self._librespot_args += [
|
|
||||||
'--alsa-mixer-device',
|
|
||||||
mixer_card,
|
|
||||||
'--alsa-mixer-index',
|
|
||||||
str(mixer_index),
|
|
||||||
]
|
|
||||||
if autoplay:
|
|
||||||
self._librespot_args += ['--autoplay']
|
|
||||||
if disable_gapless:
|
|
||||||
self._librespot_args += ['--disable-gapless']
|
|
||||||
if disable_discovery:
|
|
||||||
self._librespot_args += ['--disable-discovery']
|
|
||||||
if disable_audio_cache:
|
|
||||||
self._librespot_args += ['--disable-audio-cache']
|
|
||||||
if proxy:
|
|
||||||
self._librespot_args += ['--proxy', proxy]
|
|
||||||
if ap_port:
|
|
||||||
self._librespot_args += ['--ap-port', str(ap_port)]
|
|
||||||
if cache_dir:
|
|
||||||
self._librespot_args += ['--cache', os.path.expanduser(cache_dir)]
|
|
||||||
if system_cache_dir:
|
|
||||||
self._librespot_args += [
|
|
||||||
'--system-cache',
|
|
||||||
os.path.expanduser(system_cache_dir),
|
|
||||||
]
|
|
||||||
if enable_volume_normalization:
|
|
||||||
self._librespot_args += [
|
|
||||||
'--enable-volume-normalisation',
|
|
||||||
'--normalisation-method',
|
|
||||||
normalization_method,
|
|
||||||
'--normalisation-threshold',
|
|
||||||
str(normalization_threshold),
|
|
||||||
'--normalisation-attack',
|
|
||||||
str(normalization_attack),
|
|
||||||
'--normalisation-release',
|
|
||||||
str(normalization_release),
|
|
||||||
'--normalisation-knee',
|
|
||||||
str(normalization_knee),
|
|
||||||
]
|
|
||||||
|
|
||||||
if normalization_pre_gain:
|
|
||||||
self._librespot_args += [
|
|
||||||
'--normalisation-pregain',
|
|
||||||
str(normalization_pre_gain),
|
|
||||||
]
|
|
||||||
|
|
||||||
self._librespot_dump_args = self._librespot_args.copy()
|
|
||||||
if username and password:
|
|
||||||
self._librespot_args += ['--username', username, '--password', password]
|
|
||||||
self._librespot_dump_args += ['--username', username, '--password', '*****']
|
|
||||||
|
|
||||||
self._librespot_proc: Optional[subprocess.Popen] = None
|
|
||||||
self._status_thread: Optional[threading.Thread] = None
|
|
||||||
|
|
||||||
self.status: Dict[str, Any] = {
|
|
||||||
'state': 'stop',
|
|
||||||
'volume': None,
|
|
||||||
'time': None,
|
|
||||||
'elapsed': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
self.track = {
|
|
||||||
'file': None,
|
|
||||||
'url': None,
|
|
||||||
'uri': None,
|
|
||||||
'time': None,
|
|
||||||
'artist': None,
|
|
||||||
'album': None,
|
|
||||||
'title': None,
|
|
||||||
'date': None,
|
|
||||||
'track': None,
|
|
||||||
'id': None,
|
|
||||||
'x-albumuri': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
super().run()
|
|
||||||
self._status_thread = threading.Thread(target=self._get_status_check_loop())
|
|
||||||
self._status_thread.start()
|
|
||||||
|
|
||||||
while not self.should_stop():
|
|
||||||
self.logger.info(
|
|
||||||
f'Starting music.spotify backend. Librespot command line: {self._librespot_dump_args}'
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._librespot_proc = subprocess.Popen(self._librespot_args)
|
|
||||||
|
|
||||||
while not self.should_stop():
|
|
||||||
try:
|
|
||||||
if self._librespot_proc:
|
|
||||||
self._librespot_proc.wait(timeout=1.0)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.exception(e)
|
|
||||||
continue
|
|
||||||
|
|
||||||
def _get_status_check_loop(self):
|
|
||||||
def loop():
|
|
||||||
redis = get_redis()
|
|
||||||
|
|
||||||
while not self.should_stop():
|
|
||||||
msg = redis.blpop(status_queue, timeout=1)
|
|
||||||
if not msg:
|
|
||||||
continue
|
|
||||||
|
|
||||||
self._process_status_msg(json.loads(msg[1]))
|
|
||||||
|
|
||||||
return loop
|
|
||||||
|
|
||||||
def _process_status_msg(self, status):
|
|
||||||
event_type = status.get('PLAYER_EVENT')
|
|
||||||
volume = (
|
|
||||||
int(status['VOLUME']) / 655.35 if status.get('VOLUME') is not None else None
|
|
||||||
)
|
|
||||||
track_id = status.get('TRACK_ID')
|
|
||||||
old_track_id = status.get('OLD_TRACK_ID', self.track['id'])
|
|
||||||
duration = (
|
|
||||||
int(status['DURATION_MS']) / 1000.0
|
|
||||||
if status.get('DURATION_MS') is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
elapsed = (
|
|
||||||
int(status['POSITION_MS']) / 1000.0
|
|
||||||
if status.get('POSITION_MS') is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if volume is not None:
|
|
||||||
self.status['volume'] = volume
|
|
||||||
if duration is not None:
|
|
||||||
self.status['time'] = duration
|
|
||||||
if elapsed is not None:
|
|
||||||
self.status['elapsed'] = elapsed
|
|
||||||
if track_id and track_id != old_track_id:
|
|
||||||
self.track = self.spotify_get_track(track_id)
|
|
||||||
|
|
||||||
if event_type == 'playing':
|
|
||||||
self.status['state'] = 'play'
|
|
||||||
elif event_type == 'paused':
|
|
||||||
self.status['state'] = 'pause'
|
|
||||||
elif event_type in ['stopped', 'started']:
|
|
||||||
self.status['state'] = 'stop'
|
|
||||||
|
|
||||||
event_args = {
|
|
||||||
'status': self.status,
|
|
||||||
'track': self.track,
|
|
||||||
'plugin_name': 'music.spotify',
|
|
||||||
'player': self.device_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
if event_type == 'volume_set':
|
|
||||||
self.bus.post(VolumeChangeEvent(volume=volume, **event_args))
|
|
||||||
if elapsed is not None:
|
|
||||||
self.bus.post(SeekChangeEvent(position=elapsed, **event_args))
|
|
||||||
if track_id and track_id != old_track_id:
|
|
||||||
self.bus.post(NewPlayingTrackEvent(**event_args))
|
|
||||||
if event_type == 'playing':
|
|
||||||
self.bus.post(MusicPlayEvent(**event_args))
|
|
||||||
elif event_type == 'paused':
|
|
||||||
self.bus.post(MusicPauseEvent(**event_args))
|
|
||||||
elif event_type == 'stopped':
|
|
||||||
self.bus.post(MusicStopEvent(**event_args))
|
|
||||||
|
|
||||||
def on_stop(self):
|
|
||||||
if self._librespot_proc:
|
|
||||||
self.logger.info('Terminating librespot')
|
|
||||||
self._librespot_proc.terminate()
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._librespot_proc.wait(timeout=5.0)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
self.logger.warning('Librespot has not yet terminated: killing it')
|
|
||||||
self._librespot_proc.kill()
|
|
||||||
|
|
||||||
self._librespot_proc = None
|
|
||||||
|
|
||||||
if self._status_thread.is_alive():
|
|
||||||
self.logger.info('Waiting for the status check thread to terminate')
|
|
||||||
self._status_thread.join(timeout=10)
|
|
|
@ -1 +0,0 @@
|
||||||
status_queue = 'platypush/music/spotify/connect/status'
|
|
|
@ -1,26 +0,0 @@
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
from platypush.utils import get_redis
|
|
||||||
from . import status_queue
|
|
||||||
|
|
||||||
environ_variables = [
|
|
||||||
'PLAYER_EVENT',
|
|
||||||
'TRACK_ID',
|
|
||||||
'OLD_TRACK_ID',
|
|
||||||
'DURATION_MS',
|
|
||||||
'POSITION_MS',
|
|
||||||
'VOLUME',
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def on_librespot_event():
|
|
||||||
get_redis().rpush(status_queue, json.dumps({
|
|
||||||
var: os.environ[var]
|
|
||||||
for var in environ_variables
|
|
||||||
if var in os.environ
|
|
||||||
}))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
on_librespot_event()
|
|
|
@ -1,27 +0,0 @@
|
||||||
manifest:
|
|
||||||
events:
|
|
||||||
platypush.message.event.music.MusicPauseEvent: if the playback state changed to
|
|
||||||
pause
|
|
||||||
platypush.message.event.music.MusicPlayEvent: if the playback state changed to
|
|
||||||
play
|
|
||||||
platypush.message.event.music.MusicStopEvent: if the playback state changed to
|
|
||||||
stop
|
|
||||||
platypush.message.event.music.NewPlayingTrackEvent: if a new track is being played
|
|
||||||
platypush.message.event.music.VolumeChangeEvent: if the volume changes
|
|
||||||
install:
|
|
||||||
apk:
|
|
||||||
- sudo
|
|
||||||
- cargo
|
|
||||||
apt:
|
|
||||||
- sudo
|
|
||||||
- cargo
|
|
||||||
dnf:
|
|
||||||
- sudo
|
|
||||||
- cargo
|
|
||||||
pacman:
|
|
||||||
- sudo
|
|
||||||
- cargo
|
|
||||||
after:
|
|
||||||
- sudo cargo install librespot
|
|
||||||
package: platypush.backend.music.spotify
|
|
||||||
type: backend
|
|
|
@ -23,6 +23,10 @@ class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin):
|
||||||
"""
|
"""
|
||||||
Plugin to interact with the user's Spotify library and players.
|
Plugin to interact with the user's Spotify library and players.
|
||||||
|
|
||||||
|
.. warning:: I don't have a Spotify account, so I can't test this plugin. If you have
|
||||||
|
a Spotify account and you want to contribute to testing or improving this plugin,
|
||||||
|
please reach out to me.
|
||||||
|
|
||||||
In order to use this plugin to interact with your Spotify account you need to register a new app on the Spotify
|
In order to use this plugin to interact with your Spotify account you need to register a new app on the Spotify
|
||||||
developers website, whitelist the callback URL of your Platypush host and authorize the app to your account:
|
developers website, whitelist the callback URL of your Platypush host and authorize the app to your account:
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue