diff --git a/CHANGELOG.md b/CHANGELOG.md index 5813258a2..4e92c6872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ Given the high speed of development in the first phase, changes are being report ## [Unreleased] -- Added `music.spotify.connect` backend to emulate a Spotify Connect receiver through Platypush. +- Added `music.spotify` backend to emulate a Spotify Connect receiver through Platypush. + +- Added `music.spotify` plugin. ## [0.21.1] - 2021-06-22 diff --git a/generate_missing_docs.py b/generate_missing_docs.py index 37a425ed2..d302a7b8e 100644 --- a/generate_missing_docs.py +++ b/generate_missing_docs.py @@ -29,7 +29,7 @@ def generate_plugins_doc(): plugin_file = os.path.join(plugins_dir, plugin + '.rst') if not os.path.exists(plugin_file): plugin = 'platypush.plugins.' + plugin - header = '``{}``'.format(plugin) + header = '``{}``'.format('.'.join(plugin.split('.')[2:])) divider = '=' * len(header) body = '\n.. automodule:: {}\n :members:\n'.format(plugin) out = '\n'.join([header, divider, body]) @@ -62,7 +62,7 @@ def generate_backends_doc(): backend_file = os.path.join(backends_dir, backend + '.rst') if not os.path.exists(backend_file): backend = 'platypush.backend.' + backend - header = '``{}``'.format(backend) + header = '``{}``'.format('.'.join(backend.split('.')[2:])) divider = '=' * len(header) body = '\n.. automodule:: {}\n :members:\n'.format(backend) out = '\n'.join([header, divider, body]) diff --git a/platypush/backend/http/app/routes/plugins/spotify.py b/platypush/backend/http/app/routes/plugins/spotify.py new file mode 100644 index 000000000..26ee8a7c4 --- /dev/null +++ b/platypush/backend/http/app/routes/plugins/spotify.py @@ -0,0 +1,39 @@ +import json + +from flask import abort, request, Response, Blueprint + +from platypush.backend.http.app import template_folder +from platypush.common.spotify import SpotifyMixin +from platypush.utils import get_redis + +spotify = Blueprint('spotify', __name__, template_folder=template_folder) + +# Declare routes list +__routes__ = [ + spotify, +] + + +@spotify.route('/spotify/auth_callback', methods=['GET']) +def auth_callback(): + """ + This route is used as a callback URL for Spotify API authentication flows. + """ + + code = request.args.get('code') + state = request.args.get('state') + error = request.args.get('error') + + if not state: + abort(400, 'No state parameters provided') + + msg = {'error': code} if error else {'code': code} + get_redis().rpush(SpotifyMixin.get_spotify_queue_for_state(state), json.dumps(msg)) + + if error: + return Response(f'Authentication failed: {error}') + + return Response('Authentication successful. You can now close this window') + + +# vim:sw=4:ts=4:et: diff --git a/platypush/backend/http/webapp/src/components/widgets/Music/Index.vue b/platypush/backend/http/webapp/src/components/widgets/Music/Index.vue index 8a4230168..1d3c8cc3c 100644 --- a/platypush/backend/http/webapp/src/components/widgets/Music/Index.vue +++ b/platypush/backend/http/webapp/src/components/widgets/Music/Index.vue @@ -107,7 +107,7 @@ export default { try { let status = await this.request('music.mpd.status') - let track = await this.request('music.mpd.currentsong') + let track = await this.request('music.mpd.current_track') this._parseStatus(status) this._parseTrack(track) @@ -170,7 +170,7 @@ export default { async _parseTrack(track) { if (!track || track.length === 0) { - track = await this.request('music.mpd.currentsong') + track = await this.request('music.mpd.current_track') } if (!this.track) diff --git a/platypush/backend/music/spotify/__init__.py b/platypush/backend/music/spotify/__init__.py index e69de29bb..2db8a2205 100644 --- a/platypush/backend/music/spotify/__init__.py +++ b/platypush/backend/music/spotify/__init__.py @@ -0,0 +1,282 @@ +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. + + Triggers: + + * :class:`platypush.message.event.music.MusicPlayEvent` if the playback state changed to play + * :class:`platypush.message.event.music.MusicPauseEvent` if the playback state changed to pause + * :class:`platypush.message.event.music.MusicStopEvent` if the playback state changed to stop + * :class:`platypush.message.event.music.NewPlayingTrackEvent` if a new track is being played + * :class:`platypush.message.event.music.VolumeChangeEvent` if the volume changes + + 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., + normalization_attack: int = 5, + normalization_release: int = 100, + normalization_knee: float = 1., + **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, '--mixer-name', 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 += ['--device', audio_device] + else: + self._librespot_args += ['--mixer-card', mixer_card, '--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, + '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: + 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. if status.get('DURATION_MS') is not None else None + elapsed = int(status['POSITION_MS'])/1000. 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.) + 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) diff --git a/platypush/backend/music/spotify/connect/__init__.py b/platypush/backend/music/spotify/connect/__init__.py deleted file mode 100644 index ee571b866..000000000 --- a/platypush/backend/music/spotify/connect/__init__.py +++ /dev/null @@ -1,298 +0,0 @@ -import json -import os -import re -import subprocess -import threading -from typing import Optional, Dict, Any - -import requests - -from platypush.backend import Backend -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 MusicSpotifyConnectBackend(Backend): - """ - This backend uses `librespot `_ to make the Platypush node - discoverable by a device running a Spotify client or app over Spotify Connect. It can be used to stream - Spotify to the Platypush host. After the backend has started, you should see a new entry in the Spotify Connect - devices list in your app. - - Triggers: - - * :class:`platypush.message.event.music.MusicPlayEvent` if the playback state changed to play - * :class:`platypush.message.event.music.MusicPauseEvent` if the playback state changed to pause - * :class:`platypush.message.event.music.MusicStopEvent` if the playback state changed to stop - * :class:`platypush.message.event.music.NewPlayingTrackEvent` if a new track is being played - * :class:`platypush.message.event.music.VolumeChangeEvent` if the volume changes - - 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, - 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., - normalization_attack: int = 5, - normalization_release: int = 100, - normalization_knee: float = 1., - **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 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). - """ - super().__init__(**kwargs) - 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, '--mixer-name', mixer_name, '--initial-volume', str(volume), - '--volume-ctrl', volume_ctrl, '--bitrate', str(bitrate), '--emit-sink-events', - '--onevent', 'python -m platypush.backend.music.spotify.connect.event', - ] - - if audio_device: - self._librespot_args += ['--device', audio_device] - else: - self._librespot_args += ['--mixer-card', mixer_card, '--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, - '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.connect backend. Librespot command line: {self._librespot_dump_args}' - ) - - try: - self._librespot_proc = subprocess.Popen(self._librespot_args) - - while not self.should_stop(): - try: - 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 - - @staticmethod - def _parse_spotify_track(track_id: str): - info = json.loads([ - re.match(r'^\s*Spotify.Entity\s*=\s*(.*);\s*$', line).group(1) - for line in requests.get(f'https://open.spotify.com/track/{track_id}').text.split('\n') - if 'Spotify.Entity' in line - ].pop()) - - return { - 'file': info['uri'], - 'time': info['duration_ms']/1000. if info.get('duration_ms') is not None else None, - 'artist': '; '.join([ - artist['name'] for artist in info.get('artists', []) - ]), - 'album': info.get('album', {}).get('name'), - 'title': info.get('name'), - 'date': info.get('album', {}).get('release_date', '').split('-')[0], - 'track': info.get('track_number'), - 'id': info['id'], - 'x-albumuri': info.get('album', {}).get('uri'), - } - - 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. if status.get('DURATION_MS') is not None else None - elapsed = int(status['POSITION_MS'])/1000. 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._parse_spotify_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.connect', - '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.) - except subprocess.TimeoutExpired: - self.logger.warning('Librespot has not 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) diff --git a/platypush/backend/music/spotify/connect/event/__init__.py b/platypush/backend/music/spotify/event/__init__.py similarity index 100% rename from platypush/backend/music/spotify/connect/event/__init__.py rename to platypush/backend/music/spotify/event/__init__.py diff --git a/platypush/backend/music/spotify/connect/event/__main__.py b/platypush/backend/music/spotify/event/__main__.py similarity index 100% rename from platypush/backend/music/spotify/connect/event/__main__.py rename to platypush/backend/music/spotify/event/__main__.py diff --git a/platypush/common/spotify/__init__.py b/platypush/common/spotify/__init__.py new file mode 100644 index 000000000..28433e548 --- /dev/null +++ b/platypush/common/spotify/__init__.py @@ -0,0 +1,354 @@ +import json +import logging +import os +import pathlib +import re +from base64 import b64encode +from datetime import datetime, timedelta +from random import randint +from typing import Optional, Iterable +from urllib import parse + +import requests + +from platypush.config import Config +from platypush.context import get_backend +from platypush.exceptions import PlatypushException +from platypush.schemas.spotify import SpotifyTrackSchema +from platypush.utils import get_ip_or_hostname, get_redis + + +class MissingScopesException(PlatypushException): + """ + Exception raised in case of insufficient access scopes for an API call. + """ + def __init__(self, scopes: Optional[Iterable[str]] = None): + super().__init__('Missing scopes for the required API call') + self.scopes = scopes + + def __str__(self): + return f'{self._msg}: {self.scopes}' + + +class SpotifyMixin: + """ + This mixin provides a common interface to access the Spotify API. + """ + + spotify_auth_redis_prefix = 'platypush/music/spotify/auth' + spotify_required_scopes = [ + 'user-read-playback-state', + 'user-modify-playback-state', + 'user-read-currently-playing', + 'user-read-recently-played', + 'app-remote-control', + 'streaming', + 'playlist-modify-public', + 'playlist-modify-private', + 'playlist-read-private', + 'playlist-read-collaborative', + 'user-library-modify', + 'user-library-read', + ] + + def __init__(self, *, client_id: Optional[str] = None, client_secret: Optional[str] = None, **kwargs): + """ + :param client_id: Spotify client ID. + :param client_secret: Spotify client secret. + """ + self._spotify_data_dir = os.path.join(os.path.expanduser(Config.get('workdir')), 'spotify') + self._spotify_credentials_file = os.path.join(self._spotify_data_dir, 'credentials.json') + self._spotify_api_token: Optional[str] = None + self._spotify_api_token_expires_at: Optional[datetime] = None + self._spotify_user_token: Optional[str] = None + self._spotify_user_token_expires_at: Optional[datetime] = None + self._spotify_user_scopes = set() + self._spotify_refresh_token: Optional[str] = None + + if not (client_id and client_secret) and Config.get('backend.music.spotify'): + client_id, client_secret = ( + Config.get('backend.music.spotify').get('client_id'), + Config.get('backend.music.spotify').get('client_secret'), + ) + + if not (client_id and client_secret) and Config.get('music.spotify'): + client_id, client_secret = ( + Config.get('music.spotify').get('client_id'), + Config.get('music.spotify').get('client_secret'), + ) + + self._spotify_api_credentials = (client_id, client_secret) if client_id and client_secret else () + self._spotify_logger = logging.getLogger(__name__) + pathlib.Path(self._spotify_data_dir).mkdir(parents=True, exist_ok=True) + + def _spotify_assert_keys(self): + assert self._spotify_api_credentials, \ + 'No Spotify API credentials provided. ' + \ + 'Please register an app on https://developers.spotify.com' + + def _spotify_authorization_header(self) -> dict: + self._spotify_assert_keys() + return { + 'Authorization': 'Basic ' + b64encode( + f'{self._spotify_api_credentials[0]}:{self._spotify_api_credentials[1]}'.encode() + ).decode() + } + + def _spotify_load_user_credentials(self, scopes: Optional[Iterable[str]] = None): + if not os.path.isfile(self._spotify_credentials_file): + return + + with open(self._spotify_credentials_file, 'r') as f: + credentials = json.load(f) + + access_token, refresh_token, expires_at, saved_scopes = ( + credentials.get('access_token'), + credentials.get('refresh_token'), + credentials.get('expires_at'), + set(credentials.get('scopes', [])), + ) + + self._spotify_refresh_token = refresh_token + self._spotify_user_scopes = self._spotify_user_scopes.union(saved_scopes) + + if not expires_at: + self._spotify_user_token = None + self._spotify_user_token_expires_at = None + return + + expires_at = datetime.fromisoformat(expires_at) + if expires_at <= datetime.now(): + self._spotify_user_token = None + self._spotify_user_token_expires_at = None + return + + missing_scopes = [scope for scope in (scopes or []) if scope not in saved_scopes] + if missing_scopes: + self._spotify_user_token = None + self._spotify_user_token_expires_at = None + raise MissingScopesException(scopes=missing_scopes) + + self._spotify_user_token = access_token + self._spotify_user_token_expires_at = expires_at + + def _spotify_save_user_credentials(self, access_token: str, refresh_token: str, expires_at: datetime, + scopes: Optional[Iterable[str]] = None): + self._spotify_user_token = access_token + self._spotify_user_token_expires_at = expires_at + self._spotify_refresh_token = refresh_token + self._spotify_user_scopes = self._spotify_user_scopes.union(set(scopes or [])) + + with open(self._spotify_credentials_file, 'w') as f: + os.chmod(self._spotify_credentials_file, 0o600) + json.dump({ + 'access_token': access_token, + 'refresh_token': refresh_token, + 'expires_at': datetime.isoformat(expires_at), + 'scopes': list(self._spotify_user_scopes), + }, f) + + def spotify_api_authenticate(self): + """ + Authenticate to the Spotify API for requests that don't require access to user data. + """ + if not (self._spotify_api_token or self._spotify_user_token): + self._spotify_load_user_credentials() + + self._spotify_assert_keys() + if (self._spotify_user_token and self._spotify_user_token_expires_at > datetime.now()) or \ + (self._spotify_api_token and self._spotify_api_token_expires_at > datetime.now()): + # Already authenticated + return + + rs = requests.post( + 'https://accounts.spotify.com/api/token', + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + **self._spotify_authorization_header(), + }, + data={ + 'grant_type': 'client_credentials', + } + ) + + rs.raise_for_status() + rs = rs.json() + self._spotify_api_token = rs.get('access_token') + self._spotify_api_token_expires_at = datetime.now() + timedelta(seconds=rs.get('expires_in')) + + def spotify_user_authenticate(self, scopes: Optional[Iterable[str]] = None): + """ + Authenticate to the Spotify API for requests that require access to user data. + """ + if self._spotify_user_token: + return + + try: + self._spotify_load_user_credentials(scopes=scopes or self.spotify_required_scopes) + if self._spotify_user_token: + return + + if self._spotify_refresh_token: + try: + self._spotify_refresh_user_token() + return + except Exception as e: + self._spotify_logger.error(f'Unable to refresh the user access token: {e}') + except MissingScopesException as e: + self._spotify_logger.warning(e) + + http = get_backend('http') + assert http, 'HTTP backend not configured' + callback_url = '{scheme}://{host}:{port}/spotify/auth_callback'.format( + scheme="https" if http.ssl_context else "http", + host=get_ip_or_hostname(), + port=http.port, + ) + + state = b64encode(bytes([randint(0, 255) for _ in range(18)])).decode() + self._spotify_logger.warning('\n\nUnauthenticated Spotify session or scopes not provided by the user. Please ' + 'open the following URL in a browser to authenticate:\n' + 'https://accounts.spotify.com/authorize?client_id=' + f'{self._spotify_api_credentials[0]}&' + f'response_type=code&redirect_uri={parse.quote(callback_url, safe="")}' + f'&scope={parse.quote(" ".join(scopes))}&state={state}.\n' + 'Replace the host in the callback URL with the IP/hostname of this machine ' + f'accessible to your browser if required, and make sure to add {callback_url} ' + 'to the list of whitelisted callbacks on your Spotify application page.\n') + + redis = get_redis() + msg = json.loads(redis.blpop(self.get_spotify_queue_for_state(state))[1].decode()) + assert not msg.get('error'), f'Authentication error: {msg["error"]}' + self._spotify_user_authenticate_phase_2(code=msg['code'], callback_url=callback_url, scopes=scopes) + + def _spotify_user_authenticate_phase_2(self, code: str, callback_url: str, scopes: Iterable[str]): + rs = requests.post( + 'https://accounts.spotify.com/api/token', + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + **self._spotify_authorization_header(), + }, + data={ + 'code': code, + 'redirect_uri': callback_url, + 'grant_type': 'authorization_code', + } + ) + + rs.raise_for_status() + rs = rs.json() + self._spotify_save_user_credentials(access_token=rs.get('access_token'), + refresh_token=rs.get('refresh_token'), + scopes=scopes, + expires_at=datetime.now() + timedelta(seconds=rs['expires_in'])) + + def _spotify_refresh_user_token(self): + self._spotify_logger.debug('Refreshing user access token') + rs = requests.post( + 'https://accounts.spotify.com/api/token', + headers={ + 'Content-Type': 'application/x-www-form-urlencoded', + **self._spotify_authorization_header(), + }, + data={ + 'refresh_token': self._spotify_refresh_token, + 'grant_type': 'refresh_token', + } + ) + + rs.raise_for_status() + rs = rs.json() + self._spotify_save_user_credentials(access_token=rs.get('access_token'), + refresh_token=rs.get('refresh_token', self._spotify_refresh_token), + expires_at=datetime.now() + timedelta(seconds=rs['expires_in'])) + + @classmethod + def get_spotify_queue_for_state(cls, state: str): + return cls.spotify_auth_redis_prefix + '/' + state + + def spotify_user_call(self, url: str, method='get', scopes: Optional[Iterable[str]] = None, **kwargs) -> dict: + """ + Shortcut for ``spotify_api_call`` that requires all the application scopes if none are passed. + """ + return self.spotify_api_call(url, method=method, scopes=scopes or self.spotify_required_scopes, **kwargs) + + def spotify_api_call(self, url: str, method='get', scopes: Optional[Iterable[str]] = None, **kwargs) -> dict: + """ + Send an API request to a Spotify endpoint. + + :param url: URL to be requested. + :param method: HTTP method (default: ``get``). + :param scopes: List of scopes required by the call. + :param kwargs: Extra keyword arguments to be passed to the request. + :return: The response payload. + """ + if scopes: + self.spotify_user_authenticate(scopes=scopes) + else: + self.spotify_api_authenticate() + + method = getattr(requests, method.lower()) + rs = method( + f'https://api.spotify.com{url}', + headers={ + 'Authorization': f'Bearer {self._spotify_user_token or self._spotify_api_token}', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + **kwargs + ) + + rs.raise_for_status() + if rs.status_code != 204 and rs.text: + return rs.json() + + def spotify_get_track(self, track_id: str): + """ + Get information about a Spotify track ID. + """ + if self._spotify_api_credentials: + info = self.spotify_api_call(f'/v1/tracks/{track_id}') + else: + info = json.loads( + [ + re.match(r'^\s*Spotify.Entity\s*=\s*(.*);\s*$', line).group(1) + for line in requests.get(f'https://open.spotify.com/track/{track_id}').text.split('\n') + if 'Spotify.Entity' in line + ].pop() + ) + + return SpotifyTrackSchema().dump({ + 'file': info['uri'], + 'time': info['duration_ms']/1000. if info.get('duration_ms') is not None else None, + 'artist': '; '.join([ + artist['name'] for artist in info.get('artists', []) + ]), + 'album': info.get('album', {}).get('name'), + 'title': info.get('name'), + 'date': int(info.get('album', {}).get('release_date', '').split('-')[0]), + 'track': info.get('track_number'), + 'id': info['id'], + 'x-albumuri': info.get('album', {}).get('uri'), + }) + + # noinspection PyShadowingBuiltins + def _spotify_paginate_results(self, url: str, limit: Optional[int] = None, offset: Optional[int] = None, + type: Optional[str] = None, **kwargs) -> Iterable: + results = [] + + while url and (limit is None or len(results) < limit): + url = parse.urlparse(url) + kwargs['params'] = { + **kwargs.get('params', {}), + **({'limit': min(limit, 50)} if limit is not None else {}), + **({'offset': offset} if offset is not None else {}), + **parse.parse_qs(url.query), + } + + page = self.spotify_user_call(url.path, **kwargs) + if type: + page = page.pop(type + 's') + results.extend(page.pop('items', []) if isinstance(page, dict) else page) + url = page.pop('next', None) if isinstance(page, dict) else None + + return results[:limit] diff --git a/platypush/plugins/music/__init__.py b/platypush/plugins/music/__init__.py index d407aa36f..939f2b210 100644 --- a/platypush/plugins/music/__init__.py +++ b/platypush/plugins/music/__init__.py @@ -6,45 +6,80 @@ class MusicPlugin(Plugin): super().__init__(**kwargs) @action - def play(self): + def play(self, **kwargs): raise NotImplementedError() @action - def pause(self): + def pause(self, **kwargs): raise NotImplementedError() @action - def stop(self): + def stop(self, **kwargs): raise NotImplementedError() @action - def next(self): + def next(self, **kwargs): raise NotImplementedError() @action - def previous(self): + def previous(self, **kwargs): raise NotImplementedError() @action - def set_volume(self, volume): + def set_volume(self, volume, **kwargs): raise NotImplementedError() @action - def seek(self, position): + def volup(self, delta, **kwargs): raise NotImplementedError() @action - def add(self, content): + def voldown(self, delta, **kwargs): raise NotImplementedError() @action - def clear(self): + def seek(self, position, **kwargs): raise NotImplementedError() @action - def status(self): + def add(self, resource, **kwargs): + raise NotImplementedError() + + @action + def clear(self, **kwargs): + raise NotImplementedError() + + @action + def status(self, **kwargs): + raise NotImplementedError() + + @action + def current_track(self, **kwargs): + raise NotImplementedError() + + @action + def get_playlists(self, **kwargs): + raise NotImplementedError() + + @action + def get_playlist(self, playlist, **kwargs): + raise NotImplementedError() + + @action + def add_to_playlist(self, playlist, resources, **kwargs): + raise NotImplementedError() + + @action + def remove_from_playlist(self, playlist, resources, **kwargs): + raise NotImplementedError() + + @action + def playlist_move(self, playlist, from_pos: int, to_pos: int, **kwargs): + raise NotImplementedError() + + @action + def search(self, query, *args, **kwargs): raise NotImplementedError() # vim:sw=4:ts=4:et: - diff --git a/platypush/plugins/music/mpd/__init__.py b/platypush/plugins/music/mpd/__init__.py index fdc4ec3ae..778a2d522 100644 --- a/platypush/plugins/music/mpd/__init__.py +++ b/platypush/plugins/music/mpd/__init__.py @@ -1,6 +1,7 @@ import re import threading import time +from typing import Optional, Union from platypush.plugins import action from platypush.plugins.music import MusicPlugin @@ -290,7 +291,6 @@ class MusicMpdPlugin(MusicPlugin): """ Shuffles the current playlist """ - return self._exec('shuffle') @action @@ -478,6 +478,7 @@ class MusicMpdPlugin(MusicPlugin): "elapsed": "161.967", "bitrate": "320" } + """ n_tries = 2 @@ -497,9 +498,16 @@ class MusicMpdPlugin(MusicPlugin): return None, error - # noinspection PyTypeChecker @action def currentsong(self): + """ + Legacy alias for :meth:`.current_track`. + """ + return self.current_track() + + # noinspection PyTypeChecker + @action + def current_track(self): """ :returns: The currently played track. @@ -572,7 +580,7 @@ class MusicMpdPlugin(MusicPlugin): return self._exec('playlistinfo', return_status=False) @action - def listplaylists(self): + def get_playlists(self): """ :returns: The playlists available on the server as a list of dicts. @@ -592,75 +600,96 @@ class MusicMpdPlugin(MusicPlugin): } ] """ - return sorted(self._exec('listplaylists', return_status=False), key=lambda p: p['playlist']) + @action + def listplaylists(self): + """ + Deprecated alias for :meth:`.playlists`. + """ + return self.get_playlists() + + @action + def get_playlist(self, playlist, with_tracks=False): + """ + List the items in the specified playlist. + + :param playlist: Name of the playlist + :type playlist: str + :param with_tracks: If True then the list of tracks in the playlist will be returned as well (default: False). + :type with_tracks: bool + """ + return self._exec( + 'listplaylistinfo' if with_tracks else 'listplaylist', + playlist, return_status=False) + @action def listplaylist(self, name): """ - List the items in the specified playlist (without metadata) - - :param name: Name of the playlist - :type name: str + Deprecated alias for :meth:`.playlist`. """ return self._exec('listplaylist', name, return_status=False) @action def listplaylistinfo(self, name): """ - List the items in the specified playlist (with metadata) - - :param name: Name of the playlist - :type name: str + Deprecated alias for :meth:`.playlist` with `with_tracks=True`. """ - return self._exec('listplaylistinfo', name, return_status=False) + return self.get_playlist(name, with_tracks=True) + + @action + def add_to_playlist(self, playlist, resources): + """ + Add one or multiple resources to a playlist. + + :param playlist: Playlist name + :type playlist: str + + :param resources: URI or path of the resource(s) to be added + :type resources: str or list[str] + """ + + if isinstance(resources, str): + resources = [resources] + + for res in resources: + self._exec('playlistadd', playlist, res) @action def playlistadd(self, name, uri): """ - Add one or multiple resources to a playlist. - - :param name: Playlist name - :type name: str - - :param uri: URI or path of the resource(s) to be added - :type uri: str or list[str] + Deprecated alias for :meth:`.add_to_playlist`. """ - - if isinstance(uri, str): - uri = [uri] - - for res in uri: - self._exec('playlistadd', name, res) + return self.add_to_playlist(name, uri) @action - def playlistdelete(self, name, pos): + def remove_from_playlist(self, playlist, resources): """ Remove one or multiple tracks from a playlist. - :param name: Playlist name - :type name: str + :param playlist: Playlist name + :type playlist: str - :param pos: Position or list of positions to remove - :type pos: int or list[int] + :param resources: Position or list of positions to remove + :type resources: int or list[int] """ - if isinstance(pos, str): - pos = int(pos) - if isinstance(pos, int): - pos = [pos] + if isinstance(resources, str): + resources = int(resources) + if isinstance(resources, int): + resources = [resources] - for p in sorted(pos, reverse=True): - self._exec('playlistdelete', name, p) + for p in sorted(resources, reverse=True): + self._exec('playlistdelete', playlist, p) @action - def playlistmove(self, name, from_pos, to_pos): + def playlist_move(self, playlist, from_pos, to_pos): """ - Change the position of a track in the specified playlist + Change the position of a track in the specified playlist. - :param name: Playlist name - :type name: str + :param playlist: Playlist name + :type playlist: str :param from_pos: Original track position :type from_pos: int @@ -668,7 +697,21 @@ class MusicMpdPlugin(MusicPlugin): :param to_pos: New track position :type to_pos: int """ - self._exec('playlistmove', name, from_pos, to_pos) + self._exec('playlistmove', playlist, from_pos, to_pos) + + @action + def playlistdelete(self, name, pos): + """ + Deprecated alias for :meth:`.remove_from_playlist`. + """ + return self.remove_from_playlist(name, pos) + + @action + def playlistmove(self, name, from_pos, to_pos): + """ + Deprecated alias for :meth:`.playlist_move`. + """ + return self.playlist_move(name, from_pos=from_pos, to_pos=to_pos) @action def playlistclear(self, name): @@ -771,15 +814,16 @@ class MusicMpdPlugin(MusicPlugin): # noinspection PyShadowingBuiltins @action - def search(self, filter: dict, *args, **kwargs): + def search(self, query: Optional[Union[str, dict]] = None, filter: Optional[dict] = None, *args, **kwargs): """ Free search by filter. - :param filter: Search filter (e.g. ``{"artist": "Led Zeppelin", "album": "IV"}``) + :param query: Free-text query or search structured filter (e.g. ``{"artist": "Led Zeppelin", "album": "IV"}``). + :param filter: Structured search filter (e.g. ``{"artist": "Led Zeppelin", "album": "IV"}``) - same as + ``query``, it's still here for back-compatibility reasons. :returns: list[dict] """ - - filter = self._make_filter(filter) + filter = self._make_filter(query or filter) items = self._exec('search', *filter, *args, return_status=False, **kwargs) # Spotify results first diff --git a/platypush/plugins/music/spotify.py b/platypush/plugins/music/spotify.py new file mode 100644 index 000000000..58a18490b --- /dev/null +++ b/platypush/plugins/music/spotify.py @@ -0,0 +1,969 @@ +from datetime import datetime +from typing import List, Optional, Union, Iterable + +from platypush.common.spotify import SpotifyMixin +from platypush.message.response import Response +from platypush.plugins import action +from platypush.plugins.media import PlayerState +from platypush.plugins.music import MusicPlugin +from platypush.schemas.spotify import SpotifyDeviceSchema, SpotifyStatusSchema, SpotifyTrackSchema, \ + SpotifyHistoryItemSchema, SpotifyPlaylistSchema, SpotifyAlbumSchema, SpotifyEpisodeSchema, SpotifyShowSchema, \ + SpotifyArtistSchema + + +class MusicSpotifyPlugin(MusicPlugin, SpotifyMixin): + """ + Plugin to interact with the user's Spotify library and players. + + 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: + + - Create a developer app on https://developer.spotify.com. + - Get the app's ``client_id`` and ``client_secret``. + - Whitelist the authorization callback URL on the Platypush machine, usually in the form + ``http(s)://your-platypush-hostname-or-local-ip:8008/spotify/auth_callback`` (you need the + ``http`` Platypush backend to be enabled). + - You can then authorize the app by opening the following URL in a browser: + ``https://accounts.spotify.com/authorize?client_id=&response_type=code&redirect_uri=http(s)://your-platypush-hostname-or-local-ip:8008/spotify/auth_callback&scope=&state=``. + + This is the list of scopes required for full plugin functionalities: + + - ``user-read-playback-state`` + - ``user-modify-playback-state`` + - ``user-read-currently-playing`` + - ``user-read-recently-played`` + - ``app-remote-control`` + - ``streaming`` + - ``playlist-modify-public`` + - ``playlist-modify-private`` + - ``playlist-read-private`` + - ``playlist-read-collaborative`` + - ``user-library-modify`` + - ``user-library-read`` + + Alternatively, you can call any of the methods from this plugin over HTTP API, and the full authorization URL should + be printed on the application logs/stdout. + """ + + def __init__(self, client_id: Optional[str] = None, client_secret: Optional[str] = None, **kwargs): + MusicPlugin.__init__(self, **kwargs) + SpotifyMixin.__init__(self, client_id=client_id, client_secret=client_secret, **kwargs) + self._players_by_id = {} + self._players_by_name = {} + # Playlist ID -> snapshot ID and tracks cache + self._playlist_snapshots = {} + + def _get_device(self, device: str): + dev = self._players_by_id.get(device, self._players_by_name.get(device)) + if not dev: + self.devices() + + dev = self._players_by_id.get(device, self._players_by_name.get(device)) + assert dev, f'No such device: {device}' + return dev + + @staticmethod + def _parse_datetime(dt: Optional[Union[str, datetime, int, float]]) -> Optional[datetime]: + if isinstance(dt, str): + try: + dt = float(dt) + except (ValueError, TypeError): + return datetime.fromisoformat(dt) + + if isinstance(dt, int) or isinstance(dt, float): + return datetime.fromtimestamp(dt) + + return dt + + @action + def devices(self) -> List[dict]: + """ + Get the list of players associated to the Spotify account. + + :return: .. schema:: spotify.SpotifyDeviceSchema(many=True) + """ + devices = self.spotify_user_call('/v1/me/player/devices').get('devices', []) + self._players_by_id = { + **self._players_by_id, + **{ + dev['id']: dev + for dev in devices + } + } + + self._players_by_name = { + **self._players_by_name, + **{ + dev['name']: dev + for dev in devices + } + } + + return SpotifyDeviceSchema().dump(devices, many=True) + + @action + def set_volume(self, volume: int, device: Optional[str] = None): + """ + Set the playback volume on a device. + + :param volume: Target volume as a percentage between 0 and 100. + :param device: Device ID or name. If none is specified then the currently active device will be used. + """ + if device: + device = self._get_device(device)['id'] + + self.spotify_user_call('/v1/me/player/volume', params={ + 'volume_percent': volume, + **({'device_id': device} if device else {}), + }) + + def _get_volume(self, device: Optional[str] = None) -> Optional[int]: + if device: + return self._get_device(device).get('volume') + + return self.status.output.get('volume') + + @action + def volup(self, delta: int = 5, device: Optional[str] = None): + """ + Set the volume up by a certain delta. + + :param delta: Increase the volume by this percentage amount (between 0 and 100). + :param device: Device ID or name. If none is specified then the currently active device will be used. + """ + if device: + device = self._get_device(device)['id'] + + self.spotify_user_call('/v1/me/player/volume', params={ + 'volume_percent': min(100, (self._get_volume() or 0) + delta), + **({'device_id': device} if device else {}), + }) + + @action + def voldown(self, delta: int = 5, device: Optional[str] = None): + """ + Set the volume down by a certain delta. + + :param delta: Decrease the volume by this percentage amount (between 0 and 100). + :param device: Device ID or name. If none is specified then the currently active device will be used. + """ + if device: + device = self._get_device(device)['id'] + + self.spotify_user_call('/v1/me/player/volume', params={ + 'volume_percent': max(0, (self._get_volume() or 0) - delta), + **({'device_id': device} if device else {}), + }) + + @action + def play(self, resource: Optional[str] = None, device: Optional[str] = None): + """ + Change the playback state of a device to ``PLAY`` or start playing a specific resource. + + :param resource: Resource to play, in Spotify URI format (e.g. ``spotify:track:xxxxxxxxxxxxxxxxxxxxxx``). + If none is specified then the method will change the playback state to ``PLAY``. + :param device: Device ID or name. If none is specified then the action will target the currently active device. + """ + if device: + device = self._get_device(device)['id'] + + self.spotify_user_call( + '/v1/me/player/play', + method='put', + params={ + **({'device_id': device} if device else {}), + }, + data={ + 'uris': [resource], + } if resource else {}, + ) + + @action + def pause(self, device: Optional[str] = None): + """ + Toggle paused state. + + :param device: Device ID or name. If none is specified then the action will target the currently active device. + """ + if device: + device = self._get_device(device)['id'] + + # noinspection PyUnresolvedReferences + status = self.status().output + state = 'play' \ + if status.get('device_id') != device or status.get('state') != PlayerState.PLAY.value else 'pause' + + self.spotify_user_call( + f'/v1/me/player/{state}', + method='put', + params={ + **({'device_id': device} if device else {}), + }, + ) + + @action + def pause_if_playing(self): + """ + Pause playback only if it's playing + """ + # noinspection PyUnresolvedReferences + status = self.status().output + if status.get('state') == PlayerState.PLAY.value: + self.spotify_user_call( + f'/v1/me/player/pause', + method='put', + ) + + @action + def play_if_paused(self, device: Optional[str] = None): + """ + Play only if it's paused (resume) + + :param device: Device ID or name. If none is specified then the action will target the currently active device. + """ + if device: + device = self._get_device(device)['id'] + + # noinspection PyUnresolvedReferences + status = self.status().output + if status.get('state') != PlayerState.PLAY.value: + self.spotify_user_call( + f'/v1/me/player/play', + method='put', + params={ + **({'device_id': device} if device else {}), + }, + ) + + @action + def play_if_paused_or_stopped(self): + """ + Alias for :meth:`.play_if_paused`. + """ + return self.play_if_paused() + + @action + def stop(self, **kwargs): + """ + This method is actually just an alias to :meth:`.stop`, since Spotify manages clearing playback sessions + automatically after a while for paused devices. + """ + return self.pause(**kwargs) + + @action + def start_or_transfer_playback(self, device: str): + """ + Start or transfer playback to the device specified. + + :param device: Device ID or name. + """ + device = self._get_device(device)['id'] + self.spotify_user_call( + f'/v1/me/player', + method='put', + data={ + 'device_ids': [device], + }, + ) + + @action + def next(self, device: Optional[str] = None, **kwargs): + """ + Skip to the next track. + + :param device: Device ID or name. If none is specified then the action will target the currently active device. + """ + if device: + device = self._get_device(device)['id'] + + self.spotify_user_call( + f'/v1/me/player/next', + method='post', + params={ + **({'device_id': device} if device else {}), + }, + ) + + @action + def previous(self, device: Optional[str] = None, **kwargs): + """ + Skip to the next track. + + :param device: Device ID or name. If none is specified then the action will target the currently active device. + """ + if device: + device = self._get_device(device)['id'] + + self.spotify_user_call( + f'/v1/me/player/previous', + method='post', + params={ + **({'device_id': device} if device else {}), + }, + ) + + @action + def seek(self, position: float, device: Optional[str] = None, **kwargs): + """ + Set the cursor to the specified position in the track. + + :param position: Position in seconds. + :param device: Device ID or name. If none is specified then the action will target the currently active device. + """ + if device: + device = self._get_device(device)['id'] + + self.spotify_user_call( + f'/v1/me/player/seek', + method='put', + params={ + 'position_ms': int(position * 1000), + **({'device_id': device} if device else {}), + }, + ) + + @action + def repeat(self, value: Optional[bool] = None, device: Optional[str] = None): + """ + Set or toggle repeat mode. + + :param value: If set, set the repeat state this value (true/false). Default: None (toggle current state). + :param device: Device ID or name. If none is specified then the action will target the currently active device. + """ + if device: + device = self._get_device(device)['id'] + + if value is None: + # noinspection PyUnresolvedReferences + status = self.status().output + state = 'context' \ + if status.get('device_id') != device or not status.get('repeat') else 'off' + else: + state = value is True + + self.spotify_user_call( + f'/v1/me/player/repeat', + method='put', + params={ + 'state': state, + **({'device_id': device} if device else {}), + }, + ) + + @action + def random(self, value: Optional[bool] = None, device: Optional[str] = None): + """ + Set or toggle random/shuffle mode. + + :param value: If set, set the shuffle state this value (true/false). Default: None (toggle current state). + :param device: Device ID or name. If none is specified then the action will target the currently active device. + """ + if device: + device = self._get_device(device)['id'] + + if value is None: + # noinspection PyUnresolvedReferences + status = self.status().output + state = True if status.get('device_id') != device or not status.get('random') else False + else: + state = value is True + + self.spotify_user_call( + f'/v1/me/player/shuffle', + method='put', + params={ + 'state': state, + **({'device_id': device} if device else {}), + }, + ) + + @action + def history(self, limit: int = 20, before: Optional[Union[datetime, str, int]] = None, + after: Optional[Union[datetime, str, int]] = None): + """ + Get a list of recently played track on the account. + + :param limit: Maximum number of tracks to be retrieved (default: 20, max: 50). + :param before: Retrieve only the tracks played before this timestamp, specified as a UNIX timestamp, a datetime + object or an ISO datetime string. If ``before`` is set then ``after`` cannot be set. + :param after: Retrieve only the tracks played after this timestamp, specified as a UNIX timestamp, a datetime + object or an ISO datetime string. If ``after`` is set then ``before`` cannot be set. + :return: + """ + before = self._parse_datetime(before) + after = self._parse_datetime(after) + assert not (before and after), 'before and after cannot both be set' + + results = self._spotify_paginate_results('/v1/me/player/recently-played', + limit=limit, + params={ + 'limit': min(limit, 50), + **({'before': before} if before else {}), + **({'after': after} if after else {}), + }) + + return SpotifyHistoryItemSchema().dump([ + { + **item.pop('track'), + **item, + } + for item in results + ], many=True) + + @action + def add(self, resource: str, device: Optional[str] = None, **kwargs): + """ + Add a Spotify resource (track, or episode) to the playing queue. + + :param resource: Spotify resource URI. + :param device: Device ID or name. If none is specified then the action will target the currently active device. + """ + if device: + device = self._get_device(device)['id'] + + self.spotify_user_call( + f'/v1/me/player/queue', + method='post', + params={ + 'uri': resource, + **({'device_id': device} if device else {}), + }, + ) + + @action + def clear(self, **kwargs): + pass + + @action + def status(self, **kwargs) -> dict: + """ + Get the status of the currently active player. + + :return: .. schema:: spotify.SpotifyStatusSchema + """ + status = self.spotify_user_call('/v1/me/player') + if not status: + return { + 'state': PlayerState.STOP.value, + } + + return SpotifyStatusSchema().dump(status) + + @action + def current_track(self, **kwargs) -> dict: + """ + Get the track currently playing. + + :return: .. schema:: spotify.SpotifyTrackSchema + """ + status = self.spotify_user_call('/v1/me/player') + empty_response = Response(output={}) + if not status: + # noinspection PyTypeChecker + return empty_response + + track = status.get('item', {}) + if not track: + # noinspection PyTypeChecker + return empty_response + + return SpotifyTrackSchema().dump(track) + + @action + def get_playlists(self, limit: int = 1000, offset: int = 0, user: Optional[str] = None): + """ + Get the user's playlists. + + :param limit: Maximum number of results (default: 1000). + :param offset: Return results starting from this index (default: 0). + :param user: Return the playlist owned by a specific user ID (default: currently logged in user). + :return: .. schema:: spotify.SpotifyPlaylistSchema + """ + playlists = self._spotify_paginate_results( + f'/v1/{"users/" + user if user else "me"}/playlists', + limit=limit, offset=offset + ) + + return SpotifyPlaylistSchema().dump(playlists, many=True) + + def _get_playlist(self, playlist: str) -> dict: + playlists = self.get_playlists().output + playlists = [ + pl for pl in playlists if ( + pl['id'] == playlist or + pl['uri'] == playlist or + pl['name'] == playlist + ) + ] + + assert playlists, f'No such playlist ID, URI or name: {playlist}' + return playlists[0] + + def _get_playlist_tracks_from_cache(self, id: str, snapshot_id: str, limit: Optional[int] = None, + offset: int = 0) -> Optional[Iterable]: + snapshot = self._playlist_snapshots.get(id) + if ( + not snapshot or + snapshot['snapshot_id'] != snapshot_id or + (limit is None and snapshot['limit'] is not None) + ): + return + + if limit is not None and snapshot['limit'] is not None: + stored_range = (snapshot['limit'], snapshot['limit'] + snapshot['offset']) + requested_range = (limit, limit + offset) + if requested_range[0] < stored_range[0] or requested_range[1] > stored_range[1]: + return + + return snapshot['tracks'] + + def _cache_playlist_data(self, id: str, snapshot_id: str, tracks: Iterable[dict], limit: Optional[int] = None, + offset: int = 0, **_): + self._playlist_snapshots[id] = { + 'id': id, + 'tracks': tracks, + 'snapshot_id': snapshot_id, + 'limit': limit, + 'offset': offset, + } + + @action + def get_playlist(self, playlist: str, with_tracks: bool = True, limit: Optional[int] = None, offset: int = 0): + """ + Get a playlist content. + + :param playlist: Playlist name, ID or URI. + :param with_tracks: Return also the playlist tracks (default: false, return only the metadata). + :param limit: If ``with_tracks`` is True, retrieve this maximum amount of tracks + (default: None, get all tracks). + :param offset: If ``with_tracks`` is True, retrieve tracks starting from this index (default: 0). + :return: .. schema:: spotify.SpotifyPlaylistSchema + """ + playlist = self._get_playlist(playlist) + if with_tracks: + playlist['tracks'] = self._get_playlist_tracks_from_cache( + playlist['id'], snapshot_id=playlist['snapshot_id'], + limit=limit, offset=offset + ) + + if playlist['tracks'] is None: + playlist['tracks'] = [ + { + **track, + 'track': { + **track['track'], + 'position': offset+i+1, + } + } + for i, track in enumerate(self._spotify_paginate_results( + f'/v1/playlists/{playlist["id"]}/tracks', + limit=limit, offset=offset + )) + ] + + self._cache_playlist_data(**playlist, limit=limit, offset=offset) + + return SpotifyPlaylistSchema().dump(playlist) + + @action + def add_to_playlist(self, playlist: str, resources: Union[str, Iterable[str]], position: Optional[int] = None): + """ + Add one or more items to a playlist. + + :param playlist: Playlist name, ID or URI. + :param resources: URI(s) of the resource(s) to be added. + :param position: At what (1-based) position the tracks should be inserted (default: append to the end). + """ + playlist = self._get_playlist(playlist) + response = self.spotify_user_call( + f'/v1/playlists/{playlist["id"]}/tracks', + method='post', + params={ + **({'position': position} if position is not None else {}), + }, + json={ + 'uris': [ + uri.strip() for uri in ( + resources.split(',') if isinstance(resources, str) else resources + ) + ] + } + ) + + snapshot_id = response.get('snapshot_id') + assert snapshot_id is not None, 'Could not save playlist' + + @action + def remove_from_playlist(self, playlist: str, resources: Union[str, Iterable[str]]): + """ + Remove one or more items from a playlist. + + :param playlist: Playlist name, ID or URI. + :param resources: URI(s) of the resource(s) to be removed. A maximum of 100 tracks can be provided at once. + """ + playlist = self._get_playlist(playlist) + response = self.spotify_user_call( + f'/v1/playlists/{playlist["id"]}/tracks', + method='delete', + json={ + 'tracks': [ + {'uri': uri.strip()} + for uri in ( + resources.split(',') if isinstance(resources, str) else resources + ) + ] + } + ) + + snapshot_id = response.get('snapshot_id') + assert snapshot_id is not None, 'Could not save playlist' + + @action + def playlist_move(self, playlist: str, from_pos: int, to_pos: int, range_length: int = 1, + resources: Optional[Union[str, Iterable[str]]] = None, **_): + """ + Move or replace elements in a playlist. + + :param playlist: Playlist name, ID or URI. + :param from_pos: Move tracks starting from this position (the first element has index 1). + :param to_pos: Move tracks to this position (1-based index). + :param range_length: Number of tracks to move (default: 1). + :param resources: If specified, then replace the items from `from_pos` to `from_pos+range_length` with the + specified set of Spotify URIs (it must be a collection with the same length as the range). + """ + playlist = self._get_playlist(playlist) + response = self.spotify_user_call( + f'/v1/playlists/{playlist["id"]}/tracks', + method='put', + json={ + 'range_start': int(from_pos) + 1, + 'range_length': int(range_length), + 'insert_before': int(to_pos) + 1, + **({'uris': [ + uri.strip() for uri in ( + resources.split(',') if isinstance(resources, str) else resources + ) + ]} if resources else {}) + } + ) + + snapshot_id = response.get('snapshot_id') + assert snapshot_id is not None, 'Could not save playlist' + + # noinspection PyShadowingBuiltins + @staticmethod + def _make_filter(query: Union[str, dict], **filter) -> str: + if filter: + query = { + **({'any': query} if isinstance(query, str) else {}), + **filter, + } + + if isinstance(query, str): + return query + + q = query['any'] if 'any' in query else '' + for attr in ['artist', 'track', 'album', 'year']: + if attr in query: + q += f' {attr}:{query[attr]}' + + return q.strip() + + # noinspection PyShadowingBuiltins + @action + def search(self, query: Optional[Union[str, dict]] = None, limit: int = 50, offset: int = 0, type: str = 'track', + **filter) -> Iterable[dict]: + """ + Search for tracks matching a certain criteria. + + :param query: Search filter. It can either be a free-text or a structured query. In the latter case the + following fields are supported: + + - ``any``: Search for anything that matches this text. + - ``uri``: Search the following Spotify ID/URI or list of IDs/URIs. + - ``artist``: Filter by artist. + - ``track``: Filter by track name. + - ``album``: Filter by album name. + - ``year``: Filter by year (dash-separated ranges are supported). + + :param limit: Maximum number of results (default: 50). + :param offset: Return results starting from this index (default: 0). + :param type: Type of results to be returned. Supported: ``album``, ``artist``, ``playlist``, ``track``, ``show`` + and ``episode`` (default: ``track``). + :param filter: Alternative key-value way of representing a structured query. + :return: + If ``type=track``: + .. schema:: spotify.SpotifyTrackSchema(many=True) + If ``type=album``: + .. schema:: spotify.SpotifyAlbumSchema(many=True) + If ``type=artist``: + .. schema:: spotify.SpotifyArtistSchema(many=True) + If ``type=playlist``: + .. schema:: spotify.SpotifyPlaylistSchema(many=True) + If ``type=episode``: + .. schema:: spotify.SpotifyEpisodeSchema(many=True) + If ``type=show``: + .. schema:: spotify.SpotifyShowSchema(many=True) + + """ + uri = { + **(query if isinstance(query, dict) else {}), + **filter, + }.get('uri', []) + + uris = uri.split(',') if isinstance(uri, str) else uri + params = { + 'ids': ','.join([uri.split(':')[-1].strip() for uri in uris]), + } if uris else { + 'q': self._make_filter(query, **filter), + 'type': type, + } + + response = self._spotify_paginate_results( + f'/v1/{type + "s" if uris else "search"}', + limit=limit, + offset=offset, + type=type, + params=params, + ) + + if type == 'track': + return sorted( + SpotifyTrackSchema(many=True).dump(response), + key=lambda track: ( + track.get('artist'), + track.get('date'), + track.get('album'), + track.get('track'), + track.get('title'), + track.get('popularity'), + ) + ) + + schema_class = None + if type == 'playlist': + schema_class = SpotifyPlaylistSchema + if type == 'album': + schema_class = SpotifyAlbumSchema + if type == 'artist': + schema_class = SpotifyArtistSchema + if type == 'episode': + schema_class = SpotifyEpisodeSchema + if type == 'show': + schema_class = SpotifyShowSchema + + if schema_class: + return schema_class(many=True).dump(response) + + return response + + @action + def follow_playlist(self, playlist: str, public: bool = True): + """ + Follow a playlist. + + :param playlist: Playlist name, ID or URI. + :param public: If True (default) then the playlist will appear in the user's list of public playlists, otherwise + it won't. + """ + playlist = self._get_playlist(playlist) + self.spotify_user_call( + f'/v1/playlists/{playlist["id"]}/followers', + method='put', + json={ + 'public': public, + } + ) + + @action + def unfollow_playlist(self, playlist: str): + """ + Unfollow a playlist. + + :param playlist: Playlist name, ID or URI. + """ + playlist = self._get_playlist(playlist) + self.spotify_user_call( + f'/v1/playlists/{playlist["id"]}/followers', + method='delete', + ) + + @staticmethod + def _uris_to_id(*uris: str) -> Iterable[str]: + return [ + uri.split(':')[-1] + for uri in uris + ] + + @action + def get_albums(self, limit: int = 50, offset: int = 0) -> List[dict]: + """ + Get the list of albums saved by the user. + + :param limit: Maximum number of results (default: 50). + :param offset: Return results starting from this index (default: 0). + :return: .. schema:: spotify.SpotifyAlbumSchema(many=True) + """ + return SpotifyAlbumSchema().dump( + self._spotify_paginate_results( + '/v1/me/albums', + limit=limit, + offset=offset, + ), many=True + ) + + @action + def save_albums(self, resources: Iterable[str]): + """ + Save a list of albums to the user's collection. + + :param resources: Spotify IDs or URIs of the albums to save. + """ + self.spotify_user_call( + '/v1/me/albums', + method='put', + json={'ids': self._uris_to_id(*resources)}, + ) + + @action + def remove_albums(self, resources: Iterable[str]): + """ + Remove a list of albums from the user's collection. + + :param resources: Spotify IDs or URIs of the albums to remove. + """ + self.spotify_user_call( + '/v1/me/albums', + method='delete', + json={'ids': self._uris_to_id(*resources)}, + ) + + @action + def get_tracks(self, limit: int = 100, offset: int = 0) -> List[dict]: + """ + Get the list of tracks saved by the user. + + :param limit: Maximum number of results (default: 100). + :param offset: Return results starting from this index (default: 0). + :return: .. schema:: spotify.SpotifyTrackSchema(many=True) + """ + return [ + SpotifyTrackSchema().dump(item['track']) + for item in self._spotify_paginate_results( + '/v1/me/tracks', + limit=limit, + offset=offset + ) + ] + + @action + def save_tracks(self, resources: Iterable[str]): + """ + Save a list of tracks to the user's collection. + + :param resources: Spotify IDs or URIs of the tracks to save. + """ + self.spotify_user_call( + '/v1/me/tracks', + method='put', + json={'ids': self._uris_to_id(*resources)}, + ) + + @action + def remove_tracks(self, resources: Iterable[str]): + """ + Remove a list of tracks from the user's collection. + + :param resources: Spotify IDs or URIs of the tracks to remove. + """ + self.spotify_user_call( + '/v1/me/tracks', + method='delete', + json={'ids': self._uris_to_id(*resources)}, + ) + + @action + def get_episodes(self, limit: int = 50, offset: int = 0) -> List[dict]: + """ + Get the list of episodes saved by the user. + + :param limit: Maximum number of results (default: 50). + :param offset: Return results starting from this index (default: 0). + :return: .. schema:: spotify.SpotifyEpisodeSchema(many=True) + """ + return SpotifyEpisodeSchema().dump( + self._spotify_paginate_results( + '/v1/me/episodes', + limit=limit, + offset=offset, + ), many=True + ) + + @action + def save_episodes(self, resources: Iterable[str]): + """ + Save a list of episodes to the user's collection. + + :param resources: Spotify IDs or URIs of the episodes to save. + """ + self.spotify_user_call( + '/v1/me/episodes', + method='put', + json={'ids': self._uris_to_id(*resources)}, + ) + + @action + def remove_episodes(self, resources: Iterable[str]): + """ + Remove a list of episodes from the user's collection. + + :param resources: Spotify IDs or URIs of the episodes to remove. + """ + self.spotify_user_call( + '/v1/me/episodes', + method='delete', + json={'ids': self._uris_to_id(*resources)}, + ) + + @action + def get_shows(self, limit: int = 50, offset: int = 0) -> List[dict]: + """ + Get the list of shows saved by the user. + + :param limit: Maximum number of results (default: 50). + :param offset: Return results starting from this index (default: 0). + :return: .. schema:: spotify.SpotifyShowSchema(many=True) + """ + return SpotifyShowSchema().dump( + self._spotify_paginate_results( + '/v1/me/shows', + limit=limit, + offset=offset, + ), many=True + ) + + @action + def save_shows(self, resources: Iterable[str]): + """ + Save a list of shows to the user's collection. + + :param resources: Spotify IDs or URIs of the shows to save. + """ + self.spotify_user_call( + '/v1/me/shows', + method='put', + json={'ids': self._uris_to_id(*resources)}, + ) + + @action + def remove_shows(self, resources: Iterable[str]): + """ + Remove a list of shows from the user's collection. + + :param resources: Spotify IDs or URIs of the shows to remove. + """ + self.spotify_user_call( + '/v1/me/shows', + method='delete', + json={'ids': self._uris_to_id(*resources)}, + ) diff --git a/platypush/schemas/spotify.py b/platypush/schemas/spotify.py new file mode 100644 index 000000000..9edea7374 --- /dev/null +++ b/platypush/schemas/spotify.py @@ -0,0 +1,292 @@ +from datetime import datetime +from typing import Union, Optional + +from marshmallow import fields, pre_dump +from marshmallow.schema import Schema +from marshmallow.validate import OneOf, Range + +from platypush.plugins.media import PlayerState + +device_types = [ + 'Unknown', + 'Computer', + 'Tablet', + 'Smartphone', + 'Speaker', + 'TV', + 'AVR', + 'STB', + 'Audio dongle', +] + + +def normalize_datetime(dt: str) -> Optional[datetime]: + if not dt: + return + if dt.endswith('Z'): + dt = dt[:-1] + '+00:00' + return datetime.fromisoformat(dt) + + +class SpotifySchema(Schema): + @staticmethod + def _normalize_timestamp(t: Union[str, datetime]) -> datetime: + if isinstance(t, str): + # Replace the "Z" suffix with "+00:00" + t = datetime.fromisoformat(t[:-1] + '+00:00') + + return t + + +class SpotifyDeviceSchema(SpotifySchema): + id = fields.String(required=True, dump_only=True, metadata=dict(description='Device unique ID')) + name = fields.String(required=True, metadata=dict(description='Device name')) + type = fields.String(attribute='deviceType', required=True, validate=OneOf(device_types), + metadata=dict(description=f'Supported types: [{", ".join(device_types)}]')) + volume = fields.Int(attribute='volume_percent', validate=Range(min=0, max=100), + metadata=dict(description='Player volume in percentage [0-100]')) + is_active = fields.Boolean(required=True, dump_only=True, + metadata=dict(description='True if the device is currently active')) + is_restricted = fields.Boolean(required=True, metadata=dict(description='True if the device has restricted access')) + is_private_session = fields.Boolean(required=False, + metadata=dict(description='True if the device is currently playing a private ' + 'session')) + + +class SpotifyTrackSchema(SpotifySchema): + id = fields.String(required=True, dump_only=True, metadata=dict(description='Spotify ID')) + uri = fields.String(required=True, dump_only=True, metadata=dict(description='Spotify URI')) + file = fields.String(required=True, dump_only=True, + metadata=dict(description='Cross-compatibility file ID (same as uri)')) + title = fields.String(attribute='name', required=True, metadata=dict(description='Track title')) + artist = fields.String(metadata=dict(description='Track artist')) + album = fields.String(metadata=dict(description='Track album')) + image_url = fields.String(metadata=dict(description='Album image URL')) + date = fields.Int(metadata=dict(description='Track year release date')) + track = fields.Int(attribute='track_number', metadata=dict(description='Album track number')) + duration = fields.Float(metadata=dict(description='Track duration in seconds')) + popularity = fields.Int(metadata=dict(description='Popularity between 0 and 100')) + type = fields.Constant('track') + + @pre_dump + def normalize_fields(self, data, **_): + album = data.pop('album', {}) + if album and isinstance(album, dict): + data['album'] = album['name'] + data['date'] = int(album.get('release_date', '').split('-')[0]) + data['x-albumuri'] = album['uri'] + if album.get('images'): + data['image_url'] = album['images'][0]['url'] + + artists = data.pop('artists', []) + if artists: + data['artist'] = '; '.join([ + artist['name'] for artist in artists + ]) + + duration_ms = data.pop('duration_ms', None) + if duration_ms: + data['duration'] = duration_ms/1000. + + return data + + +class SpotifyAlbumSchema(SpotifySchema): + id = fields.String(required=True, dump_only=True, metadata=dict(description='Spotify ID')) + uri = fields.String(required=True, dump_only=True, metadata=dict(description='Spotify URI')) + name = fields.String(required=True, metadata=dict(description='Name/title')) + artist = fields.String(metadata=dict(description='Artist')) + image_url = fields.String(metadata=dict(description='Image URL')) + date = fields.Int(metadata=dict(description='Release date')) + tracks = fields.Nested(SpotifyTrackSchema, many=True, metadata=dict(description='List of tracks on the album')) + popularity = fields.Int(metadata=dict(description='Popularity between 0 and 100')) + type = fields.Constant('album') + + @pre_dump + def normalize(self, data, **_): + album = data.pop('album', data) + tracks = album.pop('tracks', {}).pop('items', []) + if tracks: + album['tracks'] = tracks + + artists = album.pop('artists', []) + if artists: + album['artist'] = ';'.join([artist['name'] for artist in artists]) + + date = album.pop('release_date', None) + if date: + album['date'] = date.split('-')[0] + + images = album.pop('images', []) + if images: + album['image_url'] = images[0]['url'] + + return album + + +class SpotifyUserSchema(SpotifySchema): + id = fields.String(required=True, dump_only=True) + display_name = fields.String(required=True) + uri = fields.String(required=True, dump_only=True) + + +class SpotifyPlaylistTrackSchema(SpotifyTrackSchema): + position = fields.Int(validate=Range(min=1), metadata=dict(description='Position of the track in the playlist')) + added_at = fields.DateTime(metadata=dict(description='When the track was added to the playlist')) + added_by = fields.Nested(SpotifyUserSchema, metadata=dict(description='User that added the track')) + type = fields.Constant('playlist') + + +class SpotifyStatusSchema(SpotifySchema): + device_id = fields.String(required=True, dump_only=True, metadata=dict(description='Playing device unique ID')) + device_name = fields.String(required=True, metadata=dict(description='Playing device name')) + state = fields.String(required=True, validate=OneOf([s.value for s in PlayerState]), + metadata=dict(description=f'Supported types: [{", ".join([s.value for s in PlayerState])}]')) + volume = fields.Int(validate=Range(min=0, max=100), required=False, + metadata=dict(description='Player volume in percentage [0-100]')) + elapsed = fields.Float(required=False, metadata=dict(description='Time elapsed into the current track')) + time = fields.Float(required=False, metadata=dict(description='Duration of the current track')) + repeat = fields.Boolean(metadata=dict(description='True if the device is in repeat mode')) + random = fields.Boolean(attribute='shuffle_mode', + metadata=dict(description='True if the device is in shuffle mode')) + track = fields.Nested(SpotifyTrackSchema, metadata=dict(description='Information about the current track')) + + @pre_dump + def normalize_fields(self, data, **_): + device = data.pop('device', {}) + if device: + data['device_id'] = device['id'] + data['device_name'] = device['name'] + if device.get('volume_percent') is not None: + data['volume'] = device['volume_percent'] + + elapsed = data.pop('progress_ms', None) + if elapsed is not None: + data['elapsed'] = int(elapsed)/1000. + + track = data.pop('item', {}) + if track: + data['track'] = track + + duration = track.get('duration_ms') + if duration is not None: + data['time'] = int(duration)/1000. + + is_playing = data.pop('is_playing', None) + if is_playing is True: + data['state'] = PlayerState.PLAY.value + elif is_playing is False: + data['state'] = PlayerState.PAUSE.value + + repeat = data.pop('repeat_state', None) + if repeat is not None: + data['repeat'] = False if repeat == 'off' else True + + return data + + +class SpotifyHistoryItemSchema(SpotifyTrackSchema): + played_at = fields.DateTime(metadata=dict(description='Item play datetime')) + + @pre_dump + def _normalize_timestamps(self, data, **_): + played_at = data.pop('played_at', None) + if played_at: + data['played_at'] = self._normalize_timestamp(played_at) + + return data + + +class SpotifyPlaylistSchema(SpotifySchema): + id = fields.String(required=True, dump_only=True) + uri = fields.String(required=True, dump_only=True, metadata=dict( + description='Playlist unique Spotify URI' + )) + name = fields.String(required=True) + description = fields.String() + owner = fields.Nested(SpotifyUserSchema, metadata=dict( + description='Playlist owner data' + )) + collaborative = fields.Boolean() + public = fields.Boolean() + snapshot_id = fields.String(dump_only=True, metadata=dict( + description='Playlist snapshot ID - it changes when the playlist is modified' + )) + tracks = fields.Nested(SpotifyPlaylistTrackSchema, many=True, metadata=dict( + description='List of tracks in the playlist' + )) + + @pre_dump + def _normalize_tracks(self, data, **_): + if 'tracks' in data: + if not isinstance(data['tracks'], list): + data.pop('tracks') + else: + data['tracks'] = [ + { + **track['track'], + 'added_at': normalize_datetime(track.get('added_at')), + 'added_by': track.get('added_by'), + } + if isinstance(track.get('track'), dict) else track + for track in data['tracks'] + ] + + return data + + +class SpotifyEpisodeSchema(SpotifyTrackSchema): + description = fields.String(metadata=dict(description='Episode description')) + show = fields.String(metadata=dict(description='Episode show name')) + type = fields.Constant('episode') + + @pre_dump + def normalize_fields(self, data, **_): + data = data.pop('episode', data) + + # Cross-compatibility with SpotifyTrackSchema + show = data.pop('show', {}) + data['artist'] = data['album'] = data['show'] = show.get('name') + data['x-albumuri'] = show['uri'] + images = data.pop('images', show.pop('images', [])) + if images: + data['image_url'] = images[0]['url'] + + return data + + +class SpotifyShowSchema(SpotifyAlbumSchema): + description = fields.String(metadata=dict(description='Show description')) + publisher = fields.String(metadata=dict(description='Show publisher name')) + type = fields.Constant('show') + + @pre_dump + def normalize_fields(self, data, **_): + data = data.pop('show', data) + + # Cross-compatibility with SpotifyAlbumSchema + data['artist'] = data.get('publisher', data.get('name')) + images = data.pop('images', []) + if images: + data['image_url'] = images[0]['url'] + + return data + + +class SpotifyArtistSchema(SpotifySchema): + id = fields.String(metadata=dict(description='Spotify ID')) + uri = fields.String(metadata=dict(description='Spotify URI')) + name = fields.String(metadata=dict(description='Artist name')) + genres = fields.List(fields.String, metadata=dict(description='Artist genres')) + popularity = fields.Int(metadata=dict(description='Popularity between 0 and 100')) + image_url = fields.String(metadata=dict(description='Image URL')) + type = fields.Constant('artist') + + @pre_dump + def normalize_fields(self, data, **_): + data = data.pop('artist', data) + images = data.pop('images', []) + if images: + data['image_url'] = images[0]['url'] + + return data diff --git a/platypush/utils/__init__.py b/platypush/utils/__init__.py index abb4de70d..2c337677b 100644 --- a/platypush/utils/__init__.py +++ b/platypush/utils/__init__.py @@ -279,7 +279,7 @@ def is_process_alive(pid): def get_ip_or_hostname(): ip = socket.gethostbyname(socket.gethostname()) - if ip.startswith('127.'): + if ip.startswith('127.') or ip.startswith('::1'): try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.connect(('10.255.255.255', 1))