2019-04-03 15:40:03 +00:00
|
|
|
"""Support for interacting with Spotify Connect."""
|
2021-03-18 13:31:38 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
from asyncio import run_coroutine_threadsafe
|
|
|
|
import datetime as dt
|
2017-04-20 05:45:12 +00:00
|
|
|
from datetime import timedelta
|
2018-09-09 12:26:06 +00:00
|
|
|
import logging
|
2021-03-18 13:31:38 +00:00
|
|
|
from typing import Any, Callable
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-11-10 08:58:52 +00:00
|
|
|
import requests
|
2020-01-24 17:47:22 +00:00
|
|
|
from spotipy import Spotify, SpotifyException
|
2020-03-21 19:24:23 +00:00
|
|
|
from yarl import URL
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-09-06 13:52:59 +00:00
|
|
|
from homeassistant.components.media_player import BrowseMedia, MediaPlayerEntity
|
2019-02-08 22:18:18 +00:00
|
|
|
from homeassistant.components.media_player.const import (
|
2020-09-08 14:42:01 +00:00
|
|
|
MEDIA_CLASS_ALBUM,
|
|
|
|
MEDIA_CLASS_ARTIST,
|
|
|
|
MEDIA_CLASS_DIRECTORY,
|
2020-09-09 12:48:28 +00:00
|
|
|
MEDIA_CLASS_EPISODE,
|
2020-09-10 10:06:18 +00:00
|
|
|
MEDIA_CLASS_GENRE,
|
2020-09-08 14:42:01 +00:00
|
|
|
MEDIA_CLASS_PLAYLIST,
|
|
|
|
MEDIA_CLASS_PODCAST,
|
|
|
|
MEDIA_CLASS_TRACK,
|
2020-08-27 15:00:36 +00:00
|
|
|
MEDIA_TYPE_ALBUM,
|
|
|
|
MEDIA_TYPE_ARTIST,
|
2020-09-06 20:55:29 +00:00
|
|
|
MEDIA_TYPE_EPISODE,
|
2019-07-31 19:25:30 +00:00
|
|
|
MEDIA_TYPE_MUSIC,
|
|
|
|
MEDIA_TYPE_PLAYLIST,
|
2020-08-27 15:00:36 +00:00
|
|
|
MEDIA_TYPE_TRACK,
|
2020-11-21 10:38:40 +00:00
|
|
|
REPEAT_MODE_ALL,
|
|
|
|
REPEAT_MODE_OFF,
|
|
|
|
REPEAT_MODE_ONE,
|
2020-08-27 15:00:36 +00:00
|
|
|
SUPPORT_BROWSE_MEDIA,
|
2019-07-31 19:25:30 +00:00
|
|
|
SUPPORT_NEXT_TRACK,
|
|
|
|
SUPPORT_PAUSE,
|
|
|
|
SUPPORT_PLAY,
|
|
|
|
SUPPORT_PLAY_MEDIA,
|
|
|
|
SUPPORT_PREVIOUS_TRACK,
|
2020-11-21 10:38:40 +00:00
|
|
|
SUPPORT_REPEAT_SET,
|
2020-01-24 17:47:22 +00:00
|
|
|
SUPPORT_SEEK,
|
2019-07-31 19:25:30 +00:00
|
|
|
SUPPORT_SELECT_SOURCE,
|
|
|
|
SUPPORT_SHUFFLE_SET,
|
|
|
|
SUPPORT_VOLUME_SET,
|
|
|
|
)
|
2020-08-27 15:00:36 +00:00
|
|
|
from homeassistant.components.media_player.errors import BrowseError
|
2020-01-24 17:47:22 +00:00
|
|
|
from homeassistant.config_entries import ConfigEntry
|
|
|
|
from homeassistant.const import (
|
|
|
|
CONF_ID,
|
|
|
|
CONF_NAME,
|
|
|
|
STATE_IDLE,
|
|
|
|
STATE_PAUSED,
|
|
|
|
STATE_PLAYING,
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2020-01-24 17:47:22 +00:00
|
|
|
from homeassistant.core import HomeAssistant
|
2021-03-30 14:54:33 +00:00
|
|
|
from homeassistant.exceptions import HomeAssistantError
|
2020-04-26 19:25:58 +00:00
|
|
|
from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
|
2020-01-24 17:47:22 +00:00
|
|
|
from homeassistant.helpers.entity import Entity
|
|
|
|
from homeassistant.util.dt import utc_from_timestamp
|
2018-09-09 12:26:06 +00:00
|
|
|
|
2020-08-27 15:00:36 +00:00
|
|
|
from .const import (
|
|
|
|
DATA_SPOTIFY_CLIENT,
|
|
|
|
DATA_SPOTIFY_ME,
|
|
|
|
DATA_SPOTIFY_SESSION,
|
|
|
|
DOMAIN,
|
|
|
|
SPOTIFY_SCOPES,
|
|
|
|
)
|
2019-07-16 02:41:16 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
_LOGGER = logging.getLogger(__name__)
|
2019-07-16 02:41:16 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
ICON = "mdi:spotify"
|
2018-09-09 12:26:06 +00:00
|
|
|
|
|
|
|
SCAN_INTERVAL = timedelta(seconds=30)
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
SUPPORT_SPOTIFY = (
|
2020-08-27 15:00:36 +00:00
|
|
|
SUPPORT_BROWSE_MEDIA
|
|
|
|
| SUPPORT_NEXT_TRACK
|
2019-07-31 19:25:30 +00:00
|
|
|
| SUPPORT_PAUSE
|
|
|
|
| SUPPORT_PLAY
|
2020-01-24 17:47:22 +00:00
|
|
|
| SUPPORT_PLAY_MEDIA
|
2019-07-31 19:25:30 +00:00
|
|
|
| SUPPORT_PREVIOUS_TRACK
|
2020-11-21 10:38:40 +00:00
|
|
|
| SUPPORT_REPEAT_SET
|
2020-01-24 17:47:22 +00:00
|
|
|
| SUPPORT_SEEK
|
2019-07-31 19:25:30 +00:00
|
|
|
| SUPPORT_SELECT_SOURCE
|
|
|
|
| SUPPORT_SHUFFLE_SET
|
2020-01-24 17:47:22 +00:00
|
|
|
| SUPPORT_VOLUME_SET
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-12-05 14:05:12 +00:00
|
|
|
REPEAT_MODE_MAPPING_TO_HA = {
|
2020-11-21 10:38:40 +00:00
|
|
|
"context": REPEAT_MODE_ALL,
|
|
|
|
"off": REPEAT_MODE_OFF,
|
|
|
|
"track": REPEAT_MODE_ONE,
|
|
|
|
}
|
|
|
|
|
2020-12-05 14:05:12 +00:00
|
|
|
REPEAT_MODE_MAPPING_TO_SPOTIFY = {
|
|
|
|
value: key for key, value in REPEAT_MODE_MAPPING_TO_HA.items()
|
|
|
|
}
|
|
|
|
|
2020-08-27 15:00:36 +00:00
|
|
|
BROWSE_LIMIT = 48
|
|
|
|
|
2020-09-06 20:55:29 +00:00
|
|
|
MEDIA_TYPE_SHOW = "show"
|
|
|
|
|
2020-08-27 15:00:36 +00:00
|
|
|
PLAYABLE_MEDIA_TYPES = [
|
|
|
|
MEDIA_TYPE_PLAYLIST,
|
|
|
|
MEDIA_TYPE_ALBUM,
|
|
|
|
MEDIA_TYPE_ARTIST,
|
2020-09-06 20:55:29 +00:00
|
|
|
MEDIA_TYPE_EPISODE,
|
|
|
|
MEDIA_TYPE_SHOW,
|
2020-08-27 15:00:36 +00:00
|
|
|
MEDIA_TYPE_TRACK,
|
|
|
|
]
|
|
|
|
|
|
|
|
LIBRARY_MAP = {
|
2020-09-06 20:55:29 +00:00
|
|
|
"current_user_playlists": "Playlists",
|
|
|
|
"current_user_followed_artists": "Artists",
|
|
|
|
"current_user_saved_albums": "Albums",
|
|
|
|
"current_user_saved_tracks": "Tracks",
|
|
|
|
"current_user_saved_shows": "Podcasts",
|
|
|
|
"current_user_recently_played": "Recently played",
|
|
|
|
"current_user_top_artists": "Top Artists",
|
|
|
|
"current_user_top_tracks": "Top Tracks",
|
|
|
|
"categories": "Categories",
|
2020-08-27 15:00:36 +00:00
|
|
|
"featured_playlists": "Featured Playlists",
|
|
|
|
"new_releases": "New Releases",
|
|
|
|
}
|
|
|
|
|
2020-09-08 14:42:01 +00:00
|
|
|
CONTENT_TYPE_MEDIA_CLASS = {
|
2020-09-12 08:35:51 +00:00
|
|
|
"current_user_playlists": {
|
|
|
|
"parent": MEDIA_CLASS_DIRECTORY,
|
|
|
|
"children": MEDIA_CLASS_PLAYLIST,
|
|
|
|
},
|
|
|
|
"current_user_followed_artists": {
|
|
|
|
"parent": MEDIA_CLASS_DIRECTORY,
|
|
|
|
"children": MEDIA_CLASS_ARTIST,
|
|
|
|
},
|
|
|
|
"current_user_saved_albums": {
|
|
|
|
"parent": MEDIA_CLASS_DIRECTORY,
|
|
|
|
"children": MEDIA_CLASS_ALBUM,
|
|
|
|
},
|
|
|
|
"current_user_saved_tracks": {
|
|
|
|
"parent": MEDIA_CLASS_DIRECTORY,
|
|
|
|
"children": MEDIA_CLASS_TRACK,
|
|
|
|
},
|
|
|
|
"current_user_saved_shows": {
|
|
|
|
"parent": MEDIA_CLASS_DIRECTORY,
|
|
|
|
"children": MEDIA_CLASS_PODCAST,
|
|
|
|
},
|
|
|
|
"current_user_recently_played": {
|
|
|
|
"parent": MEDIA_CLASS_DIRECTORY,
|
|
|
|
"children": MEDIA_CLASS_TRACK,
|
|
|
|
},
|
|
|
|
"current_user_top_artists": {
|
|
|
|
"parent": MEDIA_CLASS_DIRECTORY,
|
|
|
|
"children": MEDIA_CLASS_ARTIST,
|
|
|
|
},
|
|
|
|
"current_user_top_tracks": {
|
|
|
|
"parent": MEDIA_CLASS_DIRECTORY,
|
|
|
|
"children": MEDIA_CLASS_TRACK,
|
|
|
|
},
|
|
|
|
"featured_playlists": {
|
|
|
|
"parent": MEDIA_CLASS_DIRECTORY,
|
|
|
|
"children": MEDIA_CLASS_PLAYLIST,
|
|
|
|
},
|
|
|
|
"categories": {"parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_GENRE},
|
|
|
|
"category_playlists": {
|
|
|
|
"parent": MEDIA_CLASS_DIRECTORY,
|
|
|
|
"children": MEDIA_CLASS_PLAYLIST,
|
|
|
|
},
|
|
|
|
"new_releases": {"parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_ALBUM},
|
|
|
|
MEDIA_TYPE_PLAYLIST: {
|
|
|
|
"parent": MEDIA_CLASS_PLAYLIST,
|
|
|
|
"children": MEDIA_CLASS_TRACK,
|
|
|
|
},
|
|
|
|
MEDIA_TYPE_ALBUM: {"parent": MEDIA_CLASS_ALBUM, "children": MEDIA_CLASS_TRACK},
|
|
|
|
MEDIA_TYPE_ARTIST: {"parent": MEDIA_CLASS_ARTIST, "children": MEDIA_CLASS_ALBUM},
|
|
|
|
MEDIA_TYPE_EPISODE: {"parent": MEDIA_CLASS_EPISODE, "children": None},
|
|
|
|
MEDIA_TYPE_SHOW: {"parent": MEDIA_CLASS_PODCAST, "children": MEDIA_CLASS_EPISODE},
|
|
|
|
MEDIA_TYPE_TRACK: {"parent": MEDIA_CLASS_TRACK, "children": None},
|
2020-09-08 14:42:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class MissingMediaInformation(BrowseError):
|
|
|
|
"""Missing media required information."""
|
|
|
|
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-09-09 12:48:28 +00:00
|
|
|
class UnknownMediaType(BrowseError):
|
|
|
|
"""Unknown media type."""
|
|
|
|
|
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
async def async_setup_entry(
|
|
|
|
hass: HomeAssistant,
|
|
|
|
entry: ConfigEntry,
|
2021-03-18 13:31:38 +00:00
|
|
|
async_add_entities: Callable[[list[Entity], bool], None],
|
2020-01-24 17:47:22 +00:00
|
|
|
) -> None:
|
|
|
|
"""Set up Spotify based on a config entry."""
|
|
|
|
spotify = SpotifyMediaPlayer(
|
|
|
|
hass.data[DOMAIN][entry.entry_id][DATA_SPOTIFY_SESSION],
|
|
|
|
hass.data[DOMAIN][entry.entry_id][DATA_SPOTIFY_CLIENT],
|
|
|
|
hass.data[DOMAIN][entry.entry_id][DATA_SPOTIFY_ME],
|
|
|
|
entry.data[CONF_ID],
|
|
|
|
entry.data[CONF_NAME],
|
2019-07-31 19:25:30 +00:00
|
|
|
)
|
2020-01-24 17:47:22 +00:00
|
|
|
async_add_entities([spotify], True)
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
def spotify_exception_handler(func):
|
|
|
|
"""Decorate Spotify calls to handle Spotify exception.
|
2018-09-09 12:26:06 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
A decorator that wraps the passed in function, catches Spotify errors,
|
|
|
|
aiohttp exceptions and handles the availability of the media player.
|
|
|
|
"""
|
2019-07-16 02:41:16 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
def wrapper(self, *args, **kwargs):
|
|
|
|
try:
|
|
|
|
result = func(self, *args, **kwargs)
|
|
|
|
self.player_available = True
|
|
|
|
return result
|
2021-03-30 14:54:33 +00:00
|
|
|
except requests.RequestException:
|
2020-01-24 17:47:22 +00:00
|
|
|
self.player_available = False
|
2021-03-30 14:54:33 +00:00
|
|
|
except SpotifyException as exc:
|
|
|
|
self.player_available = False
|
|
|
|
if exc.reason == "NO_ACTIVE_DEVICE":
|
|
|
|
raise HomeAssistantError("No active playback device found") from None
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
return wrapper
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
|
2020-04-25 16:00:57 +00:00
|
|
|
class SpotifyMediaPlayer(MediaPlayerEntity):
|
2017-04-20 05:45:12 +00:00
|
|
|
"""Representation of a Spotify controller."""
|
|
|
|
|
2020-04-26 19:25:58 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
session: OAuth2Session,
|
|
|
|
spotify: Spotify,
|
|
|
|
me: dict,
|
|
|
|
user_id: str,
|
|
|
|
name: str,
|
|
|
|
):
|
2017-04-20 05:45:12 +00:00
|
|
|
"""Initialize."""
|
2020-01-24 17:47:22 +00:00
|
|
|
self._id = user_id
|
|
|
|
self._me = me
|
|
|
|
self._name = f"Spotify {name}"
|
|
|
|
self._session = session
|
|
|
|
self._spotify = spotify
|
2020-11-08 23:46:26 +00:00
|
|
|
self._scope_ok = set(session.token["scope"].split(" ")).issuperset(
|
|
|
|
SPOTIFY_SCOPES
|
|
|
|
)
|
2017-09-04 18:47:40 +00:00
|
|
|
|
2021-03-18 13:31:38 +00:00
|
|
|
self._currently_playing: dict | None = {}
|
|
|
|
self._devices: list[dict] | None = []
|
|
|
|
self._playlist: dict | None = None
|
2020-01-24 17:47:22 +00:00
|
|
|
self._spotify: Spotify = None
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
self.player_available = False
|
2017-09-04 18:47:40 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
|
|
|
def name(self) -> str:
|
|
|
|
"""Return the name."""
|
|
|
|
return self._name
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
|
|
|
def icon(self) -> str:
|
|
|
|
"""Return the icon."""
|
|
|
|
return ICON
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
|
|
|
def available(self) -> bool:
|
|
|
|
"""Return True if entity is available."""
|
|
|
|
return self.player_available
|
2017-04-30 19:41:21 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
|
|
|
def unique_id(self) -> str:
|
|
|
|
"""Return the unique ID."""
|
|
|
|
return self._id
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def device_info(self) -> dict[str, Any]:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Return device information about this entity."""
|
|
|
|
if self._me is not None:
|
|
|
|
model = self._me["product"]
|
|
|
|
|
|
|
|
return {
|
|
|
|
"identifiers": {(DOMAIN, self._id)},
|
|
|
|
"manufacturer": "Spotify AB",
|
|
|
|
"model": f"Spotify {model}".rstrip(),
|
|
|
|
"name": self._name,
|
|
|
|
}
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def state(self) -> str | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Return the playback state."""
|
|
|
|
if not self._currently_playing:
|
|
|
|
return STATE_IDLE
|
|
|
|
if self._currently_playing["is_playing"]:
|
|
|
|
return STATE_PLAYING
|
|
|
|
return STATE_PAUSED
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def volume_level(self) -> float | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Return the device volume."""
|
|
|
|
return self._currently_playing.get("device", {}).get("volume_percent", 0) / 100
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def media_content_id(self) -> str | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Return the media URL."""
|
2020-02-14 21:58:39 +00:00
|
|
|
item = self._currently_playing.get("item") or {}
|
2020-07-14 18:37:08 +00:00
|
|
|
return item.get("uri")
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def media_content_type(self) -> str | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Return the media type."""
|
|
|
|
return MEDIA_TYPE_MUSIC
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def media_duration(self) -> int | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Duration of current playing media in seconds."""
|
|
|
|
if self._currently_playing.get("item") is None:
|
|
|
|
return None
|
|
|
|
return self._currently_playing["item"]["duration_ms"] / 1000
|
2019-07-16 02:41:16 +00:00
|
|
|
|
2017-04-20 05:45:12 +00:00
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def media_position(self) -> str | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Position of current playing media in seconds."""
|
|
|
|
if not self._currently_playing:
|
|
|
|
return None
|
|
|
|
return self._currently_playing["progress_ms"] / 1000
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def media_position_updated_at(self) -> dt.datetime | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""When was the position of the current playing media valid."""
|
|
|
|
if not self._currently_playing:
|
|
|
|
return None
|
|
|
|
return utc_from_timestamp(self._currently_playing["timestamp"] / 1000)
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def media_image_url(self) -> str | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Return the media image URL."""
|
|
|
|
if (
|
|
|
|
self._currently_playing.get("item") is None
|
|
|
|
or not self._currently_playing["item"]["album"]["images"]
|
|
|
|
):
|
|
|
|
return None
|
2020-09-06 20:55:29 +00:00
|
|
|
return fetch_image_url(self._currently_playing["item"]["album"])
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
def media_image_remotely_accessible(self) -> bool:
|
|
|
|
"""If the image url is remotely accessible."""
|
|
|
|
return False
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2017-04-30 19:41:21 +00:00
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def media_title(self) -> str | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Return the media title."""
|
2020-02-14 21:58:39 +00:00
|
|
|
item = self._currently_playing.get("item") or {}
|
|
|
|
return item.get("name")
|
2017-04-30 19:41:21 +00:00
|
|
|
|
2017-04-20 05:45:12 +00:00
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def media_artist(self) -> str | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Return the media artist."""
|
|
|
|
if self._currently_playing.get("item") is None:
|
|
|
|
return None
|
|
|
|
return ", ".join(
|
|
|
|
[artist["name"] for artist in self._currently_playing["item"]["artists"]]
|
|
|
|
)
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def media_album_name(self) -> str | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Return the media album."""
|
|
|
|
if self._currently_playing.get("item") is None:
|
|
|
|
return None
|
|
|
|
return self._currently_playing["item"]["album"]["name"]
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def media_track(self) -> int | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Track number of current playing media, music track only."""
|
2020-02-14 21:58:39 +00:00
|
|
|
item = self._currently_playing.get("item") or {}
|
|
|
|
return item.get("track_number")
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
def media_playlist(self):
|
|
|
|
"""Title of Playlist currently playing."""
|
|
|
|
if self._playlist is None:
|
|
|
|
return None
|
|
|
|
return self._playlist["name"]
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def source(self) -> str | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Return the current playback device."""
|
|
|
|
return self._currently_playing.get("device", {}).get("name")
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def source_list(self) -> list[str] | None:
|
2020-01-24 17:47:22 +00:00
|
|
|
"""Return a list of source devices."""
|
|
|
|
if not self._devices:
|
|
|
|
return None
|
|
|
|
return [device["name"] for device in self._devices]
|
2017-04-20 05:45:12 +00:00
|
|
|
|
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
def shuffle(self) -> bool:
|
|
|
|
"""Shuffling state."""
|
|
|
|
return bool(self._currently_playing.get("shuffle_state"))
|
2017-04-20 05:45:12 +00:00
|
|
|
|
2020-11-21 10:38:40 +00:00
|
|
|
@property
|
2021-03-18 13:31:38 +00:00
|
|
|
def repeat(self) -> str | None:
|
2020-11-21 10:38:40 +00:00
|
|
|
"""Return current repeat mode."""
|
|
|
|
repeat_state = self._currently_playing.get("repeat_state")
|
2020-12-05 14:05:12 +00:00
|
|
|
return REPEAT_MODE_MAPPING_TO_HA.get(repeat_state)
|
2020-11-21 10:38:40 +00:00
|
|
|
|
2017-04-20 05:45:12 +00:00
|
|
|
@property
|
2020-01-24 17:47:22 +00:00
|
|
|
def supported_features(self) -> int:
|
2017-05-02 16:18:47 +00:00
|
|
|
"""Return the media player features that are supported."""
|
2020-01-24 17:47:22 +00:00
|
|
|
if self._me["product"] != "premium":
|
|
|
|
return 0
|
|
|
|
return SUPPORT_SPOTIFY
|
2017-07-07 14:52:11 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@spotify_exception_handler
|
|
|
|
def set_volume_level(self, volume: int) -> None:
|
|
|
|
"""Set the volume level."""
|
|
|
|
self._spotify.volume(int(volume * 100))
|
|
|
|
|
|
|
|
@spotify_exception_handler
|
|
|
|
def media_play(self) -> None:
|
|
|
|
"""Start or resume playback."""
|
|
|
|
self._spotify.start_playback()
|
|
|
|
|
|
|
|
@spotify_exception_handler
|
|
|
|
def media_pause(self) -> None:
|
|
|
|
"""Pause playback."""
|
|
|
|
self._spotify.pause_playback()
|
|
|
|
|
|
|
|
@spotify_exception_handler
|
|
|
|
def media_previous_track(self) -> None:
|
|
|
|
"""Skip to previous track."""
|
|
|
|
self._spotify.previous_track()
|
|
|
|
|
|
|
|
@spotify_exception_handler
|
|
|
|
def media_next_track(self) -> None:
|
|
|
|
"""Skip to next track."""
|
|
|
|
self._spotify.next_track()
|
|
|
|
|
|
|
|
@spotify_exception_handler
|
|
|
|
def media_seek(self, position):
|
|
|
|
"""Send seek command."""
|
|
|
|
self._spotify.seek_track(int(position * 1000))
|
|
|
|
|
|
|
|
@spotify_exception_handler
|
|
|
|
def play_media(self, media_type: str, media_id: str, **kwargs) -> None:
|
|
|
|
"""Play media."""
|
|
|
|
kwargs = {}
|
|
|
|
|
2020-03-21 19:24:23 +00:00
|
|
|
# Spotify can't handle URI's with query strings or anchors
|
|
|
|
# Yet, they do generate those types of URI in their official clients.
|
|
|
|
media_id = str(URL(media_id).with_query(None).with_fragment(None))
|
|
|
|
|
2020-09-06 20:55:29 +00:00
|
|
|
if media_type in (MEDIA_TYPE_TRACK, MEDIA_TYPE_EPISODE, MEDIA_TYPE_MUSIC):
|
2020-01-24 17:47:22 +00:00
|
|
|
kwargs["uris"] = [media_id]
|
2020-08-27 15:00:36 +00:00
|
|
|
elif media_type in PLAYABLE_MEDIA_TYPES:
|
2020-01-24 17:47:22 +00:00
|
|
|
kwargs["context_uri"] = media_id
|
|
|
|
else:
|
|
|
|
_LOGGER.error("Media type %s is not supported", media_type)
|
|
|
|
return
|
|
|
|
|
2020-09-06 20:55:29 +00:00
|
|
|
if not self._currently_playing.get("device") and self._devices:
|
|
|
|
kwargs["device_id"] = self._devices[0].get("id")
|
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
self._spotify.start_playback(**kwargs)
|
|
|
|
|
|
|
|
@spotify_exception_handler
|
|
|
|
def select_source(self, source: str) -> None:
|
|
|
|
"""Select playback device."""
|
|
|
|
for device in self._devices:
|
|
|
|
if device["name"] == source:
|
|
|
|
self._spotify.transfer_playback(
|
|
|
|
device["id"], self.state == STATE_PLAYING
|
|
|
|
)
|
|
|
|
return
|
|
|
|
|
|
|
|
@spotify_exception_handler
|
|
|
|
def set_shuffle(self, shuffle: bool) -> None:
|
|
|
|
"""Enable/Disable shuffle mode."""
|
|
|
|
self._spotify.shuffle(shuffle)
|
|
|
|
|
2020-11-21 10:38:40 +00:00
|
|
|
@spotify_exception_handler
|
|
|
|
def set_repeat(self, repeat: str) -> None:
|
|
|
|
"""Set repeat mode."""
|
2020-12-05 14:05:12 +00:00
|
|
|
if repeat not in REPEAT_MODE_MAPPING_TO_SPOTIFY:
|
|
|
|
raise ValueError(f"Unsupported repeat mode: {repeat}")
|
|
|
|
self._spotify.repeat(REPEAT_MODE_MAPPING_TO_SPOTIFY[repeat])
|
2020-11-21 10:38:40 +00:00
|
|
|
|
2020-01-24 17:47:22 +00:00
|
|
|
@spotify_exception_handler
|
|
|
|
def update(self) -> None:
|
|
|
|
"""Update state and attributes."""
|
|
|
|
if not self.enabled:
|
|
|
|
return
|
|
|
|
|
|
|
|
if not self._session.valid_token or self._spotify is None:
|
|
|
|
run_coroutine_threadsafe(
|
|
|
|
self._session.async_ensure_token_valid(), self.hass.loop
|
|
|
|
).result()
|
|
|
|
self._spotify = Spotify(auth=self._session.token["access_token"])
|
|
|
|
|
|
|
|
current = self._spotify.current_playback()
|
|
|
|
self._currently_playing = current or {}
|
|
|
|
|
|
|
|
self._playlist = None
|
|
|
|
context = self._currently_playing.get("context")
|
|
|
|
if context is not None and context["type"] == MEDIA_TYPE_PLAYLIST:
|
|
|
|
self._playlist = self._spotify.playlist(current["context"]["uri"])
|
|
|
|
|
|
|
|
devices = self._spotify.devices() or {}
|
|
|
|
self._devices = devices.get("devices", [])
|
2020-08-27 15:00:36 +00:00
|
|
|
|
|
|
|
async def async_browse_media(self, media_content_type=None, media_content_id=None):
|
|
|
|
"""Implement the websocket media browsing helper."""
|
2020-09-06 20:55:29 +00:00
|
|
|
|
2020-08-27 15:00:36 +00:00
|
|
|
if not self._scope_ok:
|
2020-11-08 23:46:26 +00:00
|
|
|
_LOGGER.debug(
|
|
|
|
"Spotify scopes are not set correctly, this can impact features such as media browsing"
|
|
|
|
)
|
2020-08-27 15:00:36 +00:00
|
|
|
raise NotImplementedError
|
|
|
|
|
|
|
|
if media_content_type in [None, "library"]:
|
|
|
|
return await self.hass.async_add_executor_job(library_payload)
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
"media_content_type": media_content_type,
|
|
|
|
"media_content_id": media_content_id,
|
|
|
|
}
|
|
|
|
response = await self.hass.async_add_executor_job(
|
2020-09-06 20:55:29 +00:00
|
|
|
build_item_response, self._spotify, self._me, payload
|
2020-08-27 15:00:36 +00:00
|
|
|
)
|
|
|
|
if response is None:
|
|
|
|
raise BrowseError(
|
|
|
|
f"Media not found: {media_content_type} / {media_content_id}"
|
|
|
|
)
|
|
|
|
return response
|
|
|
|
|
|
|
|
|
2021-04-25 10:38:40 +00:00
|
|
|
def build_item_response(spotify, user, payload): # noqa: C901
|
2020-08-27 15:00:36 +00:00
|
|
|
"""Create response payload for the provided media query."""
|
2020-09-07 20:17:10 +00:00
|
|
|
media_content_type = payload["media_content_type"]
|
|
|
|
media_content_id = payload["media_content_id"]
|
2020-08-27 15:00:36 +00:00
|
|
|
title = None
|
2020-09-06 20:55:29 +00:00
|
|
|
image = None
|
|
|
|
if media_content_type == "current_user_playlists":
|
2020-08-27 15:00:36 +00:00
|
|
|
media = spotify.current_user_playlists(limit=BROWSE_LIMIT)
|
|
|
|
items = media.get("items", [])
|
2020-09-06 20:55:29 +00:00
|
|
|
elif media_content_type == "current_user_followed_artists":
|
|
|
|
media = spotify.current_user_followed_artists(limit=BROWSE_LIMIT)
|
|
|
|
items = media.get("artists", {}).get("items", [])
|
|
|
|
elif media_content_type == "current_user_saved_albums":
|
|
|
|
media = spotify.current_user_saved_albums(limit=BROWSE_LIMIT)
|
2020-09-08 13:23:29 +00:00
|
|
|
items = [item["album"] for item in media.get("items", [])]
|
2020-09-06 20:55:29 +00:00
|
|
|
elif media_content_type == "current_user_saved_tracks":
|
|
|
|
media = spotify.current_user_saved_tracks(limit=BROWSE_LIMIT)
|
2020-09-08 13:23:29 +00:00
|
|
|
items = [item["track"] for item in media.get("items", [])]
|
2020-09-06 20:55:29 +00:00
|
|
|
elif media_content_type == "current_user_saved_shows":
|
|
|
|
media = spotify.current_user_saved_shows(limit=BROWSE_LIMIT)
|
2020-09-08 13:23:29 +00:00
|
|
|
items = [item["show"] for item in media.get("items", [])]
|
2020-08-27 15:00:36 +00:00
|
|
|
elif media_content_type == "current_user_recently_played":
|
|
|
|
media = spotify.current_user_recently_played(limit=BROWSE_LIMIT)
|
2020-09-08 13:23:29 +00:00
|
|
|
items = [item["track"] for item in media.get("items", [])]
|
2020-08-27 15:00:36 +00:00
|
|
|
elif media_content_type == "current_user_top_artists":
|
|
|
|
media = spotify.current_user_top_artists(limit=BROWSE_LIMIT)
|
|
|
|
items = media.get("items", [])
|
2020-09-06 20:55:29 +00:00
|
|
|
elif media_content_type == "current_user_top_tracks":
|
|
|
|
media = spotify.current_user_top_tracks(limit=BROWSE_LIMIT)
|
|
|
|
items = media.get("items", [])
|
|
|
|
elif media_content_type == "featured_playlists":
|
|
|
|
media = spotify.featured_playlists(country=user["country"], limit=BROWSE_LIMIT)
|
|
|
|
items = media.get("playlists", {}).get("items", [])
|
|
|
|
elif media_content_type == "categories":
|
|
|
|
media = spotify.categories(country=user["country"], limit=BROWSE_LIMIT)
|
|
|
|
items = media.get("categories", {}).get("items", [])
|
|
|
|
elif media_content_type == "category_playlists":
|
|
|
|
media = spotify.category_playlists(
|
|
|
|
category_id=media_content_id,
|
|
|
|
country=user["country"],
|
|
|
|
limit=BROWSE_LIMIT,
|
|
|
|
)
|
|
|
|
category = spotify.category(media_content_id, country=user["country"])
|
|
|
|
title = category.get("name")
|
|
|
|
image = fetch_image_url(category, key="icons")
|
|
|
|
items = media.get("playlists", {}).get("items", [])
|
2020-08-27 15:00:36 +00:00
|
|
|
elif media_content_type == "new_releases":
|
2020-09-06 20:55:29 +00:00
|
|
|
media = spotify.new_releases(country=user["country"], limit=BROWSE_LIMIT)
|
2020-08-27 15:00:36 +00:00
|
|
|
items = media.get("albums", {}).get("items", [])
|
|
|
|
elif media_content_type == MEDIA_TYPE_PLAYLIST:
|
2020-09-06 20:55:29 +00:00
|
|
|
media = spotify.playlist(media_content_id)
|
2020-09-08 13:23:29 +00:00
|
|
|
items = [item["track"] for item in media.get("tracks", {}).get("items", [])]
|
2020-08-27 15:00:36 +00:00
|
|
|
elif media_content_type == MEDIA_TYPE_ALBUM:
|
2020-09-06 20:55:29 +00:00
|
|
|
media = spotify.album(media_content_id)
|
2020-08-27 15:00:36 +00:00
|
|
|
items = media.get("tracks", {}).get("items", [])
|
|
|
|
elif media_content_type == MEDIA_TYPE_ARTIST:
|
2020-09-06 20:55:29 +00:00
|
|
|
media = spotify.artist_albums(media_content_id, limit=BROWSE_LIMIT)
|
|
|
|
artist = spotify.artist(media_content_id)
|
|
|
|
title = artist.get("name")
|
|
|
|
image = fetch_image_url(artist)
|
|
|
|
items = media.get("items", [])
|
|
|
|
elif media_content_type == MEDIA_TYPE_SHOW:
|
|
|
|
media = spotify.show_episodes(media_content_id, limit=BROWSE_LIMIT)
|
|
|
|
show = spotify.show(media_content_id)
|
|
|
|
title = show.get("name")
|
|
|
|
image = fetch_image_url(show)
|
2020-08-27 15:00:36 +00:00
|
|
|
items = media.get("items", [])
|
|
|
|
else:
|
|
|
|
media = None
|
2020-09-06 13:52:59 +00:00
|
|
|
items = []
|
2020-08-27 15:00:36 +00:00
|
|
|
|
|
|
|
if media is None:
|
|
|
|
return None
|
|
|
|
|
2020-09-09 12:48:28 +00:00
|
|
|
try:
|
|
|
|
media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type]
|
|
|
|
except KeyError:
|
|
|
|
_LOGGER.debug("Unknown media type received: %s", media_content_type)
|
|
|
|
return None
|
|
|
|
|
2020-09-06 20:55:29 +00:00
|
|
|
if media_content_type == "categories":
|
2020-09-08 14:42:01 +00:00
|
|
|
media_item = BrowseMedia(
|
2020-09-06 20:55:29 +00:00
|
|
|
title=LIBRARY_MAP.get(media_content_id),
|
2020-09-12 08:35:51 +00:00
|
|
|
media_class=media_class["parent"],
|
|
|
|
children_media_class=media_class["children"],
|
2020-09-06 20:55:29 +00:00
|
|
|
media_content_id=media_content_id,
|
|
|
|
media_content_type=media_content_type,
|
|
|
|
can_play=False,
|
|
|
|
can_expand=True,
|
2020-09-08 14:42:01 +00:00
|
|
|
children=[],
|
|
|
|
)
|
|
|
|
for item in items:
|
|
|
|
try:
|
|
|
|
item_id = item["id"]
|
|
|
|
except KeyError:
|
2021-03-30 14:54:33 +00:00
|
|
|
_LOGGER.debug("Missing ID for media item: %s", item)
|
2020-09-08 14:42:01 +00:00
|
|
|
continue
|
|
|
|
media_item.children.append(
|
2020-09-06 20:55:29 +00:00
|
|
|
BrowseMedia(
|
|
|
|
title=item.get("name"),
|
2020-09-08 14:42:01 +00:00
|
|
|
media_class=MEDIA_CLASS_PLAYLIST,
|
2020-09-12 08:35:51 +00:00
|
|
|
children_media_class=MEDIA_CLASS_TRACK,
|
2020-09-08 14:42:01 +00:00
|
|
|
media_content_id=item_id,
|
2020-09-06 20:55:29 +00:00
|
|
|
media_content_type="category_playlists",
|
|
|
|
thumbnail=fetch_image_url(item, key="icons"),
|
|
|
|
can_play=False,
|
|
|
|
can_expand=True,
|
|
|
|
)
|
2020-09-08 14:42:01 +00:00
|
|
|
)
|
2020-09-10 10:06:18 +00:00
|
|
|
return media_item
|
2020-09-06 20:55:29 +00:00
|
|
|
|
2020-09-06 13:52:59 +00:00
|
|
|
if title is None:
|
|
|
|
if "name" in media:
|
|
|
|
title = media.get("name")
|
|
|
|
else:
|
|
|
|
title = LIBRARY_MAP.get(payload["media_content_id"])
|
|
|
|
|
2020-09-11 11:08:13 +00:00
|
|
|
params = {
|
2020-09-06 13:52:59 +00:00
|
|
|
"title": title,
|
2020-09-12 08:35:51 +00:00
|
|
|
"media_class": media_class["parent"],
|
|
|
|
"children_media_class": media_class["children"],
|
2020-09-06 20:55:29 +00:00
|
|
|
"media_content_id": media_content_id,
|
|
|
|
"media_content_type": media_content_type,
|
|
|
|
"can_play": media_content_type in PLAYABLE_MEDIA_TYPES,
|
2020-09-08 14:42:01 +00:00
|
|
|
"children": [],
|
2020-09-06 13:52:59 +00:00
|
|
|
"can_expand": True,
|
2020-08-27 15:00:36 +00:00
|
|
|
}
|
2020-09-08 14:42:01 +00:00
|
|
|
for item in items:
|
|
|
|
try:
|
2020-09-11 11:08:13 +00:00
|
|
|
params["children"].append(item_payload(item))
|
2020-09-09 12:48:28 +00:00
|
|
|
except (MissingMediaInformation, UnknownMediaType):
|
2020-09-08 14:42:01 +00:00
|
|
|
continue
|
2020-08-27 15:00:36 +00:00
|
|
|
|
|
|
|
if "images" in media:
|
2020-09-11 11:08:13 +00:00
|
|
|
params["thumbnail"] = fetch_image_url(media)
|
2020-09-06 20:55:29 +00:00
|
|
|
elif image:
|
2020-09-11 11:08:13 +00:00
|
|
|
params["thumbnail"] = image
|
2020-08-27 15:00:36 +00:00
|
|
|
|
2020-09-11 11:08:13 +00:00
|
|
|
return BrowseMedia(**params)
|
2020-08-27 15:00:36 +00:00
|
|
|
|
|
|
|
|
|
|
|
def item_payload(item):
|
|
|
|
"""
|
|
|
|
Create response payload for a single media item.
|
|
|
|
|
|
|
|
Used by async_browse_media.
|
|
|
|
"""
|
2020-09-08 14:42:01 +00:00
|
|
|
try:
|
|
|
|
media_type = item["type"]
|
|
|
|
media_id = item["uri"]
|
|
|
|
except KeyError as err:
|
2021-03-30 14:54:33 +00:00
|
|
|
_LOGGER.debug("Missing type or URI for media item: %s", item)
|
2020-09-08 14:42:01 +00:00
|
|
|
raise MissingMediaInformation from err
|
2020-09-06 20:55:29 +00:00
|
|
|
|
2020-09-09 12:48:28 +00:00
|
|
|
try:
|
|
|
|
media_class = CONTENT_TYPE_MEDIA_CLASS[media_type]
|
|
|
|
except KeyError as err:
|
|
|
|
_LOGGER.debug("Unknown media type received: %s", media_type)
|
|
|
|
raise UnknownMediaType from err
|
|
|
|
|
2020-09-08 14:42:01 +00:00
|
|
|
can_expand = media_type not in [
|
2020-09-06 20:55:29 +00:00
|
|
|
MEDIA_TYPE_TRACK,
|
|
|
|
MEDIA_TYPE_EPISODE,
|
|
|
|
]
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
"title": item.get("name"),
|
2020-09-12 08:35:51 +00:00
|
|
|
"media_class": media_class["parent"],
|
|
|
|
"children_media_class": media_class["children"],
|
2020-09-08 14:42:01 +00:00
|
|
|
"media_content_id": media_id,
|
|
|
|
"media_content_type": media_type,
|
|
|
|
"can_play": media_type in PLAYABLE_MEDIA_TYPES,
|
2020-09-06 20:55:29 +00:00
|
|
|
"can_expand": can_expand,
|
|
|
|
}
|
2020-09-06 13:52:59 +00:00
|
|
|
|
2020-09-06 20:55:29 +00:00
|
|
|
if "images" in item:
|
|
|
|
payload["thumbnail"] = fetch_image_url(item)
|
|
|
|
elif MEDIA_TYPE_ALBUM in item:
|
|
|
|
payload["thumbnail"] = fetch_image_url(item[MEDIA_TYPE_ALBUM])
|
2020-08-27 15:00:36 +00:00
|
|
|
|
2020-09-06 20:55:29 +00:00
|
|
|
return BrowseMedia(**payload)
|
2020-08-27 15:00:36 +00:00
|
|
|
|
|
|
|
|
|
|
|
def library_payload():
|
|
|
|
"""
|
|
|
|
Create response payload to describe contents of a specific library.
|
|
|
|
|
|
|
|
Used by async_browse_media.
|
|
|
|
"""
|
|
|
|
library_info = {
|
|
|
|
"title": "Media Library",
|
2020-09-08 14:42:01 +00:00
|
|
|
"media_class": MEDIA_CLASS_DIRECTORY,
|
2020-08-27 15:00:36 +00:00
|
|
|
"media_content_id": "library",
|
|
|
|
"media_content_type": "library",
|
|
|
|
"can_play": False,
|
|
|
|
"can_expand": True,
|
|
|
|
"children": [],
|
|
|
|
}
|
|
|
|
|
|
|
|
for item in [{"name": n, "type": t} for t, n in LIBRARY_MAP.items()]:
|
|
|
|
library_info["children"].append(
|
|
|
|
item_payload(
|
|
|
|
{"name": item["name"], "type": item["type"], "uri": item["type"]}
|
|
|
|
)
|
|
|
|
)
|
2020-09-11 11:08:13 +00:00
|
|
|
response = BrowseMedia(**library_info)
|
|
|
|
response.children_media_class = MEDIA_CLASS_DIRECTORY
|
|
|
|
return response
|
2020-08-27 15:00:36 +00:00
|
|
|
|
|
|
|
|
2020-09-06 20:55:29 +00:00
|
|
|
def fetch_image_url(item, key="images"):
|
2020-08-27 15:00:36 +00:00
|
|
|
"""Fetch image url."""
|
|
|
|
try:
|
2020-09-06 20:55:29 +00:00
|
|
|
return item.get(key, [])[0].get("url")
|
2020-08-27 15:00:36 +00:00
|
|
|
except IndexError:
|
2020-09-06 20:55:29 +00:00
|
|
|
return None
|