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 <https://github.com/librespot-org/librespot>`_ to turn Platypush into an audio client
+    compatible with Spotify Connect and discoverable by a device running a Spotify client or app. It can be used to
+    stream Spotify through the Platypush host. After the backend has started, you should see a new entry in the
+    Spotify Connect devices list in your app.
+
+    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 <https://github.com/librespot-org/librespot>`_ for instructions.
+
+    """
+
+    def __init__(self,
+                 librespot_path: str = 'librespot',
+                 device_name: Optional[str] = None,
+                 device_type: str = 'speaker',
+                 audio_backend: str = 'alsa',
+                 audio_device: Optional[str] = None,
+                 mixer: str = 'softvol',
+                 mixer_name: str = 'PCM',
+                 mixer_card: str = 'default',
+                 mixer_index: int = 0,
+                 volume: int = 100,
+                 volume_ctrl: str = 'linear',
+                 bitrate: int = 160,
+                 autoplay: bool = False,
+                 disable_gapless: bool = False,
+                 username: Optional[str] = None,
+                 password: Optional[str] = None,
+                 client_id: Optional[str] = None,
+                 client_secret: Optional[str] = None,
+                 proxy: Optional[str] = None,
+                 ap_port: Optional[int] = None,
+                 disable_discovery: bool = False,
+                 cache_dir: Optional[str] = None,
+                 system_cache_dir: Optional[str] = None,
+                 disable_audio_cache=False,
+                 enable_volume_normalization: bool = False,
+                 normalization_method: str = 'dynamic',
+                 normalization_pre_gain: Optional[float] = None,
+                 normalization_threshold: float = -1.,
+                 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 <https://developer.spotify.com/>`.
+        :param client_secret: Spotify client secret, required if you want to retrieve track and album info
+            through the Spotify Web API. You can generate one by creating a Spotify app
+            `here <https://developer.spotify.com/>`.
+        :param proxy: Optional HTTP proxy configuration.
+        :param ap_port: Spotify AP port to be used (default: default ports, i.e. 80, 443 and 4070).
+        :param disable_discovery: Disable discovery mode.
+        :param cache_dir: Data files cache directory.
+        :param system_cache_dir: System cache directory - it includes audio settings and credentials.
+        :param disable_audio_cache: Disable audio caching (default: False).
+        :param enable_volume_normalization: Play all the tracks at about the same volume (default: False).
+        :param normalization_method: If ``enable_volume_normalization=True``, this setting specifies the volume
+            normalization method. Supported values: ``basic``, ``dynamic`` (default: ``dynamic``).
+        :param normalization_pre_gain: Pre-gain applied to volume normalization if ``enable_volume_normalization=True``,
+            expressed in dB.
+        :param normalization_threshold: If ``enable_volume_normalization=True``, this setting specifies the
+            normalization threshold (in dBFS) to prevent audio clipping (default: -1.0).
+        :param normalization_attack: If ``enable_volume_normalization=True``, this setting specifies the attack time
+            (in ms) during which the dynamic limiter is reducing the gain (default: 5).
+        :param normalization_release: If ``enable_volume_normalization=True``, this setting specifies the release time
+            (in ms) for the dynamic limiter to restore the gain (default: 100).
+        :param normalization_knee: Knee steepness of the dynamic limiter (default: 1.0).
+        """
+        Backend.__init__(self, **kwargs)
+        SpotifyMixin.__init__(self, client_id=client_id, client_secret=client_secret)
+        self.device_name = device_name or Config.get('device_id')
+        self._librespot_args = [
+            librespot_path, '--name', self.device_name, '--backend', audio_backend,
+            '--device-type', device_type, '--mixer', mixer, '--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 <https://github.com/librespot-org/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 <https://github.com/librespot-org/librespot>`_ for instructions.
-
-    """
-
-    def __init__(self,
-                 librespot_path: str = 'librespot',
-                 device_name: Optional[str] = None,
-                 device_type: str = 'speaker',
-                 audio_backend: str = 'alsa',
-                 audio_device: Optional[str] = None,
-                 mixer: str = 'softvol',
-                 mixer_name: str = 'PCM',
-                 mixer_card: str = 'default',
-                 mixer_index: int = 0,
-                 volume: int = 100,
-                 volume_ctrl: str = 'linear',
-                 bitrate: int = 160,
-                 autoplay: bool = False,
-                 disable_gapless: bool = False,
-                 username: Optional[str] = None,
-                 password: Optional[str] = None,
-                 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=<client_id>&response_type=code&redirect_uri=http(s)://your-platypush-hostname-or-local-ip:8008/spotify/auth_callback&scope=<comma-separated-list-of-scopes>&state=<some-random-string>``.
+
+    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))