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 `_ 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 `_ 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 `. :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 `. :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)