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]
|
||||
|
||||
- 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
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@ def generate_plugins_doc():
|
|||
plugin_file = os.path.join(plugins_dir, plugin + '.rst')
|
||||
if not os.path.exists(plugin_file):
|
||||
plugin = 'platypush.plugins.' + plugin
|
||||
header = '``{}``'.format(plugin)
|
||||
header = '``{}``'.format('.'.join(plugin.split('.')[2:]))
|
||||
divider = '=' * len(header)
|
||||
body = '\n.. automodule:: {}\n :members:\n'.format(plugin)
|
||||
out = '\n'.join([header, divider, body])
|
||||
|
@ -62,7 +62,7 @@ def generate_backends_doc():
|
|||
backend_file = os.path.join(backends_dir, backend + '.rst')
|
||||
if not os.path.exists(backend_file):
|
||||
backend = 'platypush.backend.' + backend
|
||||
header = '``{}``'.format(backend)
|
||||
header = '``{}``'.format('.'.join(backend.split('.')[2:]))
|
||||
divider = '=' * len(header)
|
||||
body = '\n.. automodule:: {}\n :members:\n'.format(backend)
|
||||
out = '\n'.join([header, divider, body])
|
||||
|
|
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 {
|
||||
let status = await this.request('music.mpd.status')
|
||||
let track = await this.request('music.mpd.currentsong')
|
||||
let track = await this.request('music.mpd.current_track')
|
||||
|
||||
this._parseStatus(status)
|
||||
this._parseTrack(track)
|
||||
|
@ -170,7 +170,7 @@ export default {
|
|||
|
||||
async _parseTrack(track) {
|
||||
if (!track || track.length === 0) {
|
||||
track = await this.request('music.mpd.currentsong')
|
||||
track = await this.request('music.mpd.current_track')
|
||||
}
|
||||
|
||||
if (!this.track)
|
||||
|
|
|
@ -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)
|
||||
|
||||
@action
|
||||
def play(self):
|
||||
def play(self, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def pause(self):
|
||||
def pause(self, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def stop(self):
|
||||
def stop(self, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def next(self):
|
||||
def next(self, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def previous(self):
|
||||
def previous(self, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def set_volume(self, volume):
|
||||
def set_volume(self, volume, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def seek(self, position):
|
||||
def volup(self, delta, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def add(self, content):
|
||||
def voldown(self, delta, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def clear(self):
|
||||
def seek(self, position, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def status(self):
|
||||
def add(self, resource, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def clear(self, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def status(self, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def current_track(self, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def get_playlists(self, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def get_playlist(self, playlist, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def add_to_playlist(self, playlist, resources, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def remove_from_playlist(self, playlist, resources, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def playlist_move(self, playlist, from_pos: int, to_pos: int, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
@action
|
||||
def search(self, query, *args, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
# vim:sw=4:ts=4:et:
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, Union
|
||||
|
||||
from platypush.plugins import action
|
||||
from platypush.plugins.music import MusicPlugin
|
||||
|
@ -290,7 +291,6 @@ class MusicMpdPlugin(MusicPlugin):
|
|||
"""
|
||||
Shuffles the current playlist
|
||||
"""
|
||||
|
||||
return self._exec('shuffle')
|
||||
|
||||
@action
|
||||
|
@ -478,6 +478,7 @@ class MusicMpdPlugin(MusicPlugin):
|
|||
"elapsed": "161.967",
|
||||
"bitrate": "320"
|
||||
}
|
||||
|
||||
"""
|
||||
|
||||
n_tries = 2
|
||||
|
@ -497,9 +498,16 @@ class MusicMpdPlugin(MusicPlugin):
|
|||
|
||||
return None, error
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
@action
|
||||
def currentsong(self):
|
||||
"""
|
||||
Legacy alias for :meth:`.current_track`.
|
||||
"""
|
||||
return self.current_track()
|
||||
|
||||
# noinspection PyTypeChecker
|
||||
@action
|
||||
def current_track(self):
|
||||
"""
|
||||
:returns: The currently played track.
|
||||
|
||||
|
@ -572,7 +580,7 @@ class MusicMpdPlugin(MusicPlugin):
|
|||
return self._exec('playlistinfo', return_status=False)
|
||||
|
||||
@action
|
||||
def listplaylists(self):
|
||||
def get_playlists(self):
|
||||
"""
|
||||
:returns: The playlists available on the server as a list of dicts.
|
||||
|
||||
|
@ -592,75 +600,96 @@ class MusicMpdPlugin(MusicPlugin):
|
|||
}
|
||||
]
|
||||
"""
|
||||
|
||||
return sorted(self._exec('listplaylists', return_status=False),
|
||||
key=lambda p: p['playlist'])
|
||||
|
||||
@action
|
||||
def listplaylists(self):
|
||||
"""
|
||||
Deprecated alias for :meth:`.playlists`.
|
||||
"""
|
||||
return self.get_playlists()
|
||||
|
||||
@action
|
||||
def get_playlist(self, playlist, with_tracks=False):
|
||||
"""
|
||||
List the items in the specified playlist.
|
||||
|
||||
:param playlist: Name of the playlist
|
||||
:type playlist: str
|
||||
:param with_tracks: If True then the list of tracks in the playlist will be returned as well (default: False).
|
||||
:type with_tracks: bool
|
||||
"""
|
||||
return self._exec(
|
||||
'listplaylistinfo' if with_tracks else 'listplaylist',
|
||||
playlist, return_status=False)
|
||||
|
||||
@action
|
||||
def listplaylist(self, name):
|
||||
"""
|
||||
List the items in the specified playlist (without metadata)
|
||||
|
||||
:param name: Name of the playlist
|
||||
:type name: str
|
||||
Deprecated alias for :meth:`.playlist`.
|
||||
"""
|
||||
return self._exec('listplaylist', name, return_status=False)
|
||||
|
||||
@action
|
||||
def listplaylistinfo(self, name):
|
||||
"""
|
||||
List the items in the specified playlist (with metadata)
|
||||
|
||||
:param name: Name of the playlist
|
||||
:type name: str
|
||||
Deprecated alias for :meth:`.playlist` with `with_tracks=True`.
|
||||
"""
|
||||
return self._exec('listplaylistinfo', name, return_status=False)
|
||||
return self.get_playlist(name, with_tracks=True)
|
||||
|
||||
@action
|
||||
def add_to_playlist(self, playlist, resources):
|
||||
"""
|
||||
Add one or multiple resources to a playlist.
|
||||
|
||||
:param playlist: Playlist name
|
||||
:type playlist: str
|
||||
|
||||
:param resources: URI or path of the resource(s) to be added
|
||||
:type resources: str or list[str]
|
||||
"""
|
||||
|
||||
if isinstance(resources, str):
|
||||
resources = [resources]
|
||||
|
||||
for res in resources:
|
||||
self._exec('playlistadd', playlist, res)
|
||||
|
||||
@action
|
||||
def playlistadd(self, name, uri):
|
||||
"""
|
||||
Add one or multiple resources to a playlist.
|
||||
|
||||
:param name: Playlist name
|
||||
:type name: str
|
||||
|
||||
:param uri: URI or path of the resource(s) to be added
|
||||
:type uri: str or list[str]
|
||||
Deprecated alias for :meth:`.add_to_playlist`.
|
||||
"""
|
||||
|
||||
if isinstance(uri, str):
|
||||
uri = [uri]
|
||||
|
||||
for res in uri:
|
||||
self._exec('playlistadd', name, res)
|
||||
return self.add_to_playlist(name, uri)
|
||||
|
||||
@action
|
||||
def playlistdelete(self, name, pos):
|
||||
def remove_from_playlist(self, playlist, resources):
|
||||
"""
|
||||
Remove one or multiple tracks from a playlist.
|
||||
|
||||
:param name: Playlist name
|
||||
:type name: str
|
||||
:param playlist: Playlist name
|
||||
:type playlist: str
|
||||
|
||||
:param pos: Position or list of positions to remove
|
||||
:type pos: int or list[int]
|
||||
:param resources: Position or list of positions to remove
|
||||
:type resources: int or list[int]
|
||||
"""
|
||||
|
||||
if isinstance(pos, str):
|
||||
pos = int(pos)
|
||||
if isinstance(pos, int):
|
||||
pos = [pos]
|
||||
if isinstance(resources, str):
|
||||
resources = int(resources)
|
||||
if isinstance(resources, int):
|
||||
resources = [resources]
|
||||
|
||||
for p in sorted(pos, reverse=True):
|
||||
self._exec('playlistdelete', name, p)
|
||||
for p in sorted(resources, reverse=True):
|
||||
self._exec('playlistdelete', playlist, p)
|
||||
|
||||
@action
|
||||
def playlistmove(self, name, from_pos, to_pos):
|
||||
def playlist_move(self, playlist, from_pos, to_pos):
|
||||
"""
|
||||
Change the position of a track in the specified playlist
|
||||
Change the position of a track in the specified playlist.
|
||||
|
||||
:param name: Playlist name
|
||||
:type name: str
|
||||
:param playlist: Playlist name
|
||||
:type playlist: str
|
||||
|
||||
:param from_pos: Original track position
|
||||
:type from_pos: int
|
||||
|
@ -668,7 +697,21 @@ class MusicMpdPlugin(MusicPlugin):
|
|||
:param to_pos: New track position
|
||||
:type to_pos: int
|
||||
"""
|
||||
self._exec('playlistmove', name, from_pos, to_pos)
|
||||
self._exec('playlistmove', playlist, from_pos, to_pos)
|
||||
|
||||
@action
|
||||
def playlistdelete(self, name, pos):
|
||||
"""
|
||||
Deprecated alias for :meth:`.remove_from_playlist`.
|
||||
"""
|
||||
return self.remove_from_playlist(name, pos)
|
||||
|
||||
@action
|
||||
def playlistmove(self, name, from_pos, to_pos):
|
||||
"""
|
||||
Deprecated alias for :meth:`.playlist_move`.
|
||||
"""
|
||||
return self.playlist_move(name, from_pos=from_pos, to_pos=to_pos)
|
||||
|
||||
@action
|
||||
def playlistclear(self, name):
|
||||
|
@ -771,15 +814,16 @@ class MusicMpdPlugin(MusicPlugin):
|
|||
|
||||
# noinspection PyShadowingBuiltins
|
||||
@action
|
||||
def search(self, filter: dict, *args, **kwargs):
|
||||
def search(self, query: Optional[Union[str, dict]] = None, filter: Optional[dict] = None, *args, **kwargs):
|
||||
"""
|
||||
Free search by filter.
|
||||
|
||||
:param filter: Search filter (e.g. ``{"artist": "Led Zeppelin", "album": "IV"}``)
|
||||
:param query: Free-text query or search structured filter (e.g. ``{"artist": "Led Zeppelin", "album": "IV"}``).
|
||||
:param filter: Structured search filter (e.g. ``{"artist": "Led Zeppelin", "album": "IV"}``) - same as
|
||||
``query``, it's still here for back-compatibility reasons.
|
||||
:returns: list[dict]
|
||||
"""
|
||||
|
||||
filter = self._make_filter(filter)
|
||||
filter = self._make_filter(query or filter)
|
||||
items = self._exec('search', *filter, *args, return_status=False, **kwargs)
|
||||
|
||||
# Spotify results first
|
||||
|
|
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():
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
if ip.startswith('127.'):
|
||||
if ip.startswith('127.') or ip.startswith('::1'):
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.connect(('10.255.255.255', 1))
|
||||
|
|
Loading…
Reference in a new issue