Added music.spotify plugin and refactored MusicPlugin

This commit is contained in:
Fabio Manganiello 2021-07-17 22:14:15 +02:00
parent f250681a78
commit 35c4a30a63
14 changed files with 2080 additions and 361 deletions

View file

@ -5,7 +5,9 @@ Given the high speed of development in the first phase, changes are being report
## [Unreleased] ## [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 ## [0.21.1] - 2021-06-22

View file

@ -29,7 +29,7 @@ def generate_plugins_doc():
plugin_file = os.path.join(plugins_dir, plugin + '.rst') plugin_file = os.path.join(plugins_dir, plugin + '.rst')
if not os.path.exists(plugin_file): if not os.path.exists(plugin_file):
plugin = 'platypush.plugins.' + plugin plugin = 'platypush.plugins.' + plugin
header = '``{}``'.format(plugin) header = '``{}``'.format('.'.join(plugin.split('.')[2:]))
divider = '=' * len(header) divider = '=' * len(header)
body = '\n.. automodule:: {}\n :members:\n'.format(plugin) body = '\n.. automodule:: {}\n :members:\n'.format(plugin)
out = '\n'.join([header, divider, body]) out = '\n'.join([header, divider, body])
@ -62,7 +62,7 @@ def generate_backends_doc():
backend_file = os.path.join(backends_dir, backend + '.rst') backend_file = os.path.join(backends_dir, backend + '.rst')
if not os.path.exists(backend_file): if not os.path.exists(backend_file):
backend = 'platypush.backend.' + backend backend = 'platypush.backend.' + backend
header = '``{}``'.format(backend) header = '``{}``'.format('.'.join(backend.split('.')[2:]))
divider = '=' * len(header) divider = '=' * len(header)
body = '\n.. automodule:: {}\n :members:\n'.format(backend) body = '\n.. automodule:: {}\n :members:\n'.format(backend)
out = '\n'.join([header, divider, body]) out = '\n'.join([header, divider, body])

View file

@ -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:

View file

@ -107,7 +107,7 @@ export default {
try { try {
let status = await this.request('music.mpd.status') 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._parseStatus(status)
this._parseTrack(track) this._parseTrack(track)
@ -170,7 +170,7 @@ export default {
async _parseTrack(track) { async _parseTrack(track) {
if (!track || track.length === 0) { if (!track || track.length === 0) {
track = await this.request('music.mpd.currentsong') track = await this.request('music.mpd.current_track')
} }
if (!this.track) if (!this.track)

View file

@ -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)

View file

@ -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)

View file

@ -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]

View file

@ -6,45 +6,80 @@ class MusicPlugin(Plugin):
super().__init__(**kwargs) super().__init__(**kwargs)
@action @action
def play(self): def play(self, **kwargs):
raise NotImplementedError() raise NotImplementedError()
@action @action
def pause(self): def pause(self, **kwargs):
raise NotImplementedError() raise NotImplementedError()
@action @action
def stop(self): def stop(self, **kwargs):
raise NotImplementedError() raise NotImplementedError()
@action @action
def next(self): def next(self, **kwargs):
raise NotImplementedError() raise NotImplementedError()
@action @action
def previous(self): def previous(self, **kwargs):
raise NotImplementedError() raise NotImplementedError()
@action @action
def set_volume(self, volume): def set_volume(self, volume, **kwargs):
raise NotImplementedError() raise NotImplementedError()
@action @action
def seek(self, position): def volup(self, delta, **kwargs):
raise NotImplementedError() raise NotImplementedError()
@action @action
def add(self, content): def voldown(self, delta, **kwargs):
raise NotImplementedError() raise NotImplementedError()
@action @action
def clear(self): def seek(self, position, **kwargs):
raise NotImplementedError() raise NotImplementedError()
@action @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() raise NotImplementedError()
# vim:sw=4:ts=4:et: # vim:sw=4:ts=4:et:

View file

@ -1,6 +1,7 @@
import re import re
import threading import threading
import time import time
from typing import Optional, Union
from platypush.plugins import action from platypush.plugins import action
from platypush.plugins.music import MusicPlugin from platypush.plugins.music import MusicPlugin
@ -290,7 +291,6 @@ class MusicMpdPlugin(MusicPlugin):
""" """
Shuffles the current playlist Shuffles the current playlist
""" """
return self._exec('shuffle') return self._exec('shuffle')
@action @action
@ -478,6 +478,7 @@ class MusicMpdPlugin(MusicPlugin):
"elapsed": "161.967", "elapsed": "161.967",
"bitrate": "320" "bitrate": "320"
} }
""" """
n_tries = 2 n_tries = 2
@ -497,9 +498,16 @@ class MusicMpdPlugin(MusicPlugin):
return None, error return None, error
# noinspection PyTypeChecker
@action @action
def currentsong(self): 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. :returns: The currently played track.
@ -572,7 +580,7 @@ class MusicMpdPlugin(MusicPlugin):
return self._exec('playlistinfo', return_status=False) return self._exec('playlistinfo', return_status=False)
@action @action
def listplaylists(self): def get_playlists(self):
""" """
:returns: The playlists available on the server as a list of dicts. :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), return sorted(self._exec('listplaylists', return_status=False),
key=lambda p: p['playlist']) 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 @action
def listplaylist(self, name): def listplaylist(self, name):
""" """
List the items in the specified playlist (without metadata) Deprecated alias for :meth:`.playlist`.
:param name: Name of the playlist
:type name: str
""" """
return self._exec('listplaylist', name, return_status=False) return self._exec('listplaylist', name, return_status=False)
@action @action
def listplaylistinfo(self, name): def listplaylistinfo(self, name):
""" """
List the items in the specified playlist (with metadata) Deprecated alias for :meth:`.playlist` with `with_tracks=True`.
:param name: Name of the playlist
:type name: str
""" """
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 @action
def playlistadd(self, name, uri): def playlistadd(self, name, uri):
""" """
Add one or multiple resources to a playlist. Deprecated alias for :meth:`.add_to_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]
""" """
return self.add_to_playlist(name, uri)
if isinstance(uri, str):
uri = [uri]
for res in uri:
self._exec('playlistadd', name, res)
@action @action
def playlistdelete(self, name, pos): def remove_from_playlist(self, playlist, resources):
""" """
Remove one or multiple tracks from a playlist. Remove one or multiple tracks from a playlist.
:param name: Playlist name :param playlist: Playlist name
:type name: str :type playlist: str
:param pos: Position or list of positions to remove :param resources: Position or list of positions to remove
:type pos: int or list[int] :type resources: int or list[int]
""" """
if isinstance(pos, str): if isinstance(resources, str):
pos = int(pos) resources = int(resources)
if isinstance(pos, int): if isinstance(resources, int):
pos = [pos] resources = [resources]
for p in sorted(pos, reverse=True): for p in sorted(resources, reverse=True):
self._exec('playlistdelete', name, p) self._exec('playlistdelete', playlist, p)
@action @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 :param playlist: Playlist name
:type name: str :type playlist: str
:param from_pos: Original track position :param from_pos: Original track position
:type from_pos: int :type from_pos: int
@ -668,7 +697,21 @@ class MusicMpdPlugin(MusicPlugin):
:param to_pos: New track position :param to_pos: New track position
:type to_pos: int :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 @action
def playlistclear(self, name): def playlistclear(self, name):
@ -771,15 +814,16 @@ class MusicMpdPlugin(MusicPlugin):
# noinspection PyShadowingBuiltins # noinspection PyShadowingBuiltins
@action @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. 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] :returns: list[dict]
""" """
filter = self._make_filter(query or filter)
filter = self._make_filter(filter)
items = self._exec('search', *filter, *args, return_status=False, **kwargs) items = self._exec('search', *filter, *args, return_status=False, **kwargs)
# Spotify results first # Spotify results first

View file

@ -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)},
)

View file

@ -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

View file

@ -279,7 +279,7 @@ def is_process_alive(pid):
def get_ip_or_hostname(): def get_ip_or_hostname():
ip = socket.gethostbyname(socket.gethostname()) ip = socket.gethostbyname(socket.gethostname())
if ip.startswith('127.'): if ip.startswith('127.') or ip.startswith('::1'):
try: try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.connect(('10.255.255.255', 1)) sock.connect(('10.255.255.255', 1))