forked from platypush/platypush
Added music.spotify plugin and refactored MusicPlugin
This commit is contained in:
parent
f250681a78
commit
35c4a30a63
14 changed files with 2080 additions and 361 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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])
|
||||||
|
|
39
platypush/backend/http/app/routes/plugins/spotify.py
Normal file
39
platypush/backend/http/app/routes/plugins/spotify.py
Normal 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:
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
|
@ -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)
|
|
354
platypush/common/spotify/__init__.py
Normal file
354
platypush/common/spotify/__init__.py
Normal 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]
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
969
platypush/plugins/music/spotify.py
Normal file
969
platypush/plugins/music/spotify.py
Normal 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)},
|
||||||
|
)
|
292
platypush/schemas/spotify.py
Normal file
292
platypush/schemas/spotify.py
Normal 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
|
|
@ -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))
|
||||||
|
|
Loading…
Reference in a new issue