986 lines
32 KiB
Python
986 lines
32 KiB
Python
"""Support for interfacing with the XBMC/Kodi JSON-RPC API."""
|
|
from collections import OrderedDict
|
|
from functools import wraps
|
|
import logging
|
|
import re
|
|
import socket
|
|
import urllib
|
|
|
|
import aiohttp
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.kodi import SERVICE_CALL_METHOD
|
|
from homeassistant.components.kodi.const import DOMAIN
|
|
from homeassistant.components.media_player import MediaPlayerDevice, PLATFORM_SCHEMA
|
|
from homeassistant.components.media_player.const import (
|
|
MEDIA_TYPE_CHANNEL,
|
|
MEDIA_TYPE_MOVIE,
|
|
MEDIA_TYPE_MUSIC,
|
|
MEDIA_TYPE_PLAYLIST,
|
|
MEDIA_TYPE_TVSHOW,
|
|
MEDIA_TYPE_VIDEO,
|
|
SUPPORT_NEXT_TRACK,
|
|
SUPPORT_PAUSE,
|
|
SUPPORT_PLAY,
|
|
SUPPORT_PLAY_MEDIA,
|
|
SUPPORT_PREVIOUS_TRACK,
|
|
SUPPORT_SEEK,
|
|
SUPPORT_SHUFFLE_SET,
|
|
SUPPORT_STOP,
|
|
SUPPORT_TURN_OFF,
|
|
SUPPORT_TURN_ON,
|
|
SUPPORT_VOLUME_MUTE,
|
|
SUPPORT_VOLUME_SET,
|
|
SUPPORT_VOLUME_STEP,
|
|
)
|
|
from homeassistant.const import (
|
|
CONF_HOST,
|
|
CONF_NAME,
|
|
CONF_PASSWORD,
|
|
CONF_PORT,
|
|
CONF_PROXY_SSL,
|
|
CONF_TIMEOUT,
|
|
CONF_USERNAME,
|
|
EVENT_HOMEASSISTANT_STOP,
|
|
STATE_IDLE,
|
|
STATE_OFF,
|
|
STATE_PAUSED,
|
|
STATE_PLAYING,
|
|
)
|
|
from homeassistant.core import callback
|
|
from homeassistant.helpers import config_validation as cv
|
|
from homeassistant.helpers import script
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
from homeassistant.helpers.template import Template
|
|
from homeassistant.util.yaml import dump
|
|
import homeassistant.util.dt as dt_util
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
EVENT_KODI_CALL_METHOD_RESULT = "kodi_call_method_result"
|
|
|
|
CONF_TCP_PORT = "tcp_port"
|
|
CONF_TURN_ON_ACTION = "turn_on_action"
|
|
CONF_TURN_OFF_ACTION = "turn_off_action"
|
|
CONF_ENABLE_WEBSOCKET = "enable_websocket"
|
|
|
|
DEFAULT_NAME = "Kodi"
|
|
DEFAULT_PORT = 8080
|
|
DEFAULT_TCP_PORT = 9090
|
|
DEFAULT_TIMEOUT = 5
|
|
DEFAULT_PROXY_SSL = False
|
|
DEFAULT_ENABLE_WEBSOCKET = True
|
|
|
|
DEPRECATED_TURN_OFF_ACTIONS = {
|
|
None: None,
|
|
"quit": "Application.Quit",
|
|
"hibernate": "System.Hibernate",
|
|
"suspend": "System.Suspend",
|
|
"reboot": "System.Reboot",
|
|
"shutdown": "System.Shutdown",
|
|
}
|
|
|
|
# https://github.com/xbmc/xbmc/blob/master/xbmc/media/MediaType.h
|
|
MEDIA_TYPES = {
|
|
"music": MEDIA_TYPE_MUSIC,
|
|
"artist": MEDIA_TYPE_MUSIC,
|
|
"album": MEDIA_TYPE_MUSIC,
|
|
"song": MEDIA_TYPE_MUSIC,
|
|
"video": MEDIA_TYPE_VIDEO,
|
|
"set": MEDIA_TYPE_PLAYLIST,
|
|
"musicvideo": MEDIA_TYPE_VIDEO,
|
|
"movie": MEDIA_TYPE_MOVIE,
|
|
"tvshow": MEDIA_TYPE_TVSHOW,
|
|
"season": MEDIA_TYPE_TVSHOW,
|
|
"episode": MEDIA_TYPE_TVSHOW,
|
|
# Type 'channel' is used for radio or tv streams from pvr
|
|
"channel": MEDIA_TYPE_CHANNEL,
|
|
# Type 'audio' is used for audio media, that Kodi couldn't scroblle
|
|
"audio": MEDIA_TYPE_MUSIC,
|
|
}
|
|
|
|
SUPPORT_KODI = (
|
|
SUPPORT_PAUSE
|
|
| SUPPORT_VOLUME_SET
|
|
| SUPPORT_VOLUME_MUTE
|
|
| SUPPORT_PREVIOUS_TRACK
|
|
| SUPPORT_NEXT_TRACK
|
|
| SUPPORT_SEEK
|
|
| SUPPORT_PLAY_MEDIA
|
|
| SUPPORT_STOP
|
|
| SUPPORT_SHUFFLE_SET
|
|
| SUPPORT_PLAY
|
|
| SUPPORT_VOLUME_STEP
|
|
)
|
|
|
|
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
|
{
|
|
vol.Required(CONF_HOST): cv.string,
|
|
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
|
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
|
vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port,
|
|
vol.Optional(CONF_PROXY_SSL, default=DEFAULT_PROXY_SSL): cv.boolean,
|
|
vol.Optional(CONF_TURN_ON_ACTION): cv.SCRIPT_SCHEMA,
|
|
vol.Optional(CONF_TURN_OFF_ACTION): vol.Any(
|
|
cv.SCRIPT_SCHEMA, vol.In(DEPRECATED_TURN_OFF_ACTIONS)
|
|
),
|
|
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
|
|
vol.Inclusive(CONF_USERNAME, "auth"): cv.string,
|
|
vol.Inclusive(CONF_PASSWORD, "auth"): cv.string,
|
|
vol.Optional(
|
|
CONF_ENABLE_WEBSOCKET, default=DEFAULT_ENABLE_WEBSOCKET
|
|
): cv.boolean,
|
|
}
|
|
)
|
|
|
|
|
|
def _check_deprecated_turn_off(hass, turn_off_action):
|
|
"""Create an equivalent script for old turn off actions."""
|
|
if isinstance(turn_off_action, str):
|
|
method = DEPRECATED_TURN_OFF_ACTIONS[turn_off_action]
|
|
new_config = OrderedDict(
|
|
[
|
|
("service", "{}.{}".format(DOMAIN, SERVICE_CALL_METHOD)),
|
|
(
|
|
"data_template",
|
|
OrderedDict([("entity_id", "{{ entity_id }}"), ("method", method)]),
|
|
),
|
|
]
|
|
)
|
|
example_conf = dump(OrderedDict([(CONF_TURN_OFF_ACTION, new_config)]))
|
|
_LOGGER.warning(
|
|
"The '%s' action for turn off Kodi is deprecated and "
|
|
"will cease to function in a future release. You need to "
|
|
"change it for a generic Home Assistant script sequence, "
|
|
"which is, for this turn_off action, like this:\n%s",
|
|
turn_off_action,
|
|
example_conf,
|
|
)
|
|
new_config["data_template"] = OrderedDict(
|
|
[
|
|
(key, Template(value, hass))
|
|
for key, value in new_config["data_template"].items()
|
|
]
|
|
)
|
|
turn_off_action = [new_config]
|
|
return turn_off_action
|
|
|
|
|
|
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
|
|
"""Set up the Kodi platform."""
|
|
if DOMAIN not in hass.data:
|
|
hass.data[DOMAIN] = dict()
|
|
|
|
unique_id = None
|
|
# Is this a manual configuration?
|
|
if discovery_info is None:
|
|
name = config.get(CONF_NAME)
|
|
host = config.get(CONF_HOST)
|
|
port = config.get(CONF_PORT)
|
|
tcp_port = config.get(CONF_TCP_PORT)
|
|
encryption = config.get(CONF_PROXY_SSL)
|
|
websocket = config.get(CONF_ENABLE_WEBSOCKET)
|
|
else:
|
|
name = "{} ({})".format(DEFAULT_NAME, discovery_info.get("hostname"))
|
|
host = discovery_info.get("host")
|
|
port = discovery_info.get("port")
|
|
tcp_port = DEFAULT_TCP_PORT
|
|
encryption = DEFAULT_PROXY_SSL
|
|
websocket = DEFAULT_ENABLE_WEBSOCKET
|
|
properties = discovery_info.get("properties")
|
|
if properties is not None:
|
|
unique_id = properties.get("uuid", None)
|
|
|
|
# Only add a device once, so discovered devices do not override manual
|
|
# config.
|
|
ip_addr = socket.gethostbyname(host)
|
|
if ip_addr in hass.data[DOMAIN]:
|
|
return
|
|
|
|
# If we got an unique id, check that it does not exist already.
|
|
# This is necessary as netdisco does not deterministally return the same
|
|
# advertisement when the service is offered over multiple IP addresses.
|
|
if unique_id is not None:
|
|
for device in hass.data[DOMAIN].values():
|
|
if device.unique_id == unique_id:
|
|
return
|
|
|
|
entity = KodiDevice(
|
|
hass,
|
|
name=name,
|
|
host=host,
|
|
port=port,
|
|
tcp_port=tcp_port,
|
|
encryption=encryption,
|
|
username=config.get(CONF_USERNAME),
|
|
password=config.get(CONF_PASSWORD),
|
|
turn_on_action=config.get(CONF_TURN_ON_ACTION),
|
|
turn_off_action=config.get(CONF_TURN_OFF_ACTION),
|
|
timeout=config.get(CONF_TIMEOUT),
|
|
websocket=websocket,
|
|
unique_id=unique_id,
|
|
)
|
|
|
|
hass.data[DOMAIN][ip_addr] = entity
|
|
async_add_entities([entity], update_before_add=True)
|
|
|
|
|
|
def cmd(func):
|
|
"""Catch command exceptions."""
|
|
|
|
@wraps(func)
|
|
async def wrapper(obj, *args, **kwargs):
|
|
"""Wrap all command methods."""
|
|
import jsonrpc_base
|
|
|
|
try:
|
|
await func(obj, *args, **kwargs)
|
|
except jsonrpc_base.jsonrpc.TransportError as exc:
|
|
# If Kodi is off, we expect calls to fail.
|
|
if obj.state == STATE_OFF:
|
|
log_function = _LOGGER.info
|
|
else:
|
|
log_function = _LOGGER.error
|
|
log_function(
|
|
"Error calling %s on entity %s: %r", func.__name__, obj.entity_id, exc
|
|
)
|
|
|
|
return wrapper
|
|
|
|
|
|
class KodiDevice(MediaPlayerDevice):
|
|
"""Representation of a XBMC/Kodi device."""
|
|
|
|
def __init__(
|
|
self,
|
|
hass,
|
|
name,
|
|
host,
|
|
port,
|
|
tcp_port,
|
|
encryption=False,
|
|
username=None,
|
|
password=None,
|
|
turn_on_action=None,
|
|
turn_off_action=None,
|
|
timeout=DEFAULT_TIMEOUT,
|
|
websocket=True,
|
|
unique_id=None,
|
|
):
|
|
"""Initialize the Kodi device."""
|
|
import jsonrpc_async
|
|
import jsonrpc_websocket
|
|
|
|
self.hass = hass
|
|
self._name = name
|
|
self._unique_id = unique_id
|
|
self._media_position_updated_at = None
|
|
self._media_position = None
|
|
|
|
kwargs = {"timeout": timeout, "session": async_get_clientsession(hass)}
|
|
|
|
if username is not None:
|
|
kwargs["auth"] = aiohttp.BasicAuth(username, password)
|
|
image_auth_string = "{}:{}@".format(username, password)
|
|
else:
|
|
image_auth_string = ""
|
|
|
|
http_protocol = "https" if encryption else "http"
|
|
ws_protocol = "wss" if encryption else "ws"
|
|
|
|
self._http_url = "{}://{}:{}/jsonrpc".format(http_protocol, host, port)
|
|
self._image_url = "{}://{}{}:{}/image".format(
|
|
http_protocol, image_auth_string, host, port
|
|
)
|
|
self._ws_url = "{}://{}:{}/jsonrpc".format(ws_protocol, host, tcp_port)
|
|
|
|
self._http_server = jsonrpc_async.Server(self._http_url, **kwargs)
|
|
if websocket:
|
|
# Setup websocket connection
|
|
self._ws_server = jsonrpc_websocket.Server(self._ws_url, **kwargs)
|
|
|
|
# Register notification listeners
|
|
self._ws_server.Player.OnPause = self.async_on_speed_event
|
|
self._ws_server.Player.OnPlay = self.async_on_speed_event
|
|
self._ws_server.Player.OnAVStart = self.async_on_speed_event
|
|
self._ws_server.Player.OnAVChange = self.async_on_speed_event
|
|
self._ws_server.Player.OnResume = self.async_on_speed_event
|
|
self._ws_server.Player.OnSpeedChanged = self.async_on_speed_event
|
|
self._ws_server.Player.OnSeek = self.async_on_speed_event
|
|
self._ws_server.Player.OnStop = self.async_on_stop
|
|
self._ws_server.Application.OnVolumeChanged = self.async_on_volume_changed
|
|
self._ws_server.System.OnQuit = self.async_on_quit
|
|
self._ws_server.System.OnRestart = self.async_on_quit
|
|
self._ws_server.System.OnSleep = self.async_on_quit
|
|
|
|
def on_hass_stop(event):
|
|
"""Close websocket connection when hass stops."""
|
|
self.hass.async_create_task(self._ws_server.close())
|
|
|
|
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
|
|
else:
|
|
self._ws_server = None
|
|
|
|
# Script creation for the turn on/off config options
|
|
if turn_on_action is not None:
|
|
turn_on_action = script.Script(
|
|
self.hass,
|
|
turn_on_action,
|
|
"{} turn ON script".format(self.name),
|
|
self.async_update_ha_state(True),
|
|
)
|
|
if turn_off_action is not None:
|
|
turn_off_action = script.Script(
|
|
self.hass,
|
|
_check_deprecated_turn_off(hass, turn_off_action),
|
|
"{} turn OFF script".format(self.name),
|
|
)
|
|
self._turn_on_action = turn_on_action
|
|
self._turn_off_action = turn_off_action
|
|
self._enable_websocket = websocket
|
|
self._players = list()
|
|
self._properties = {}
|
|
self._item = {}
|
|
self._app_properties = {}
|
|
|
|
@callback
|
|
def async_on_speed_event(self, sender, data):
|
|
"""Handle player changes between playing and paused."""
|
|
self._properties["speed"] = data["player"]["speed"]
|
|
|
|
if not hasattr(data["item"], "id"):
|
|
# If no item id is given, perform a full update
|
|
force_refresh = True
|
|
else:
|
|
# If a new item is playing, force a complete refresh
|
|
force_refresh = data["item"]["id"] != self._item.get("id")
|
|
|
|
self.async_schedule_update_ha_state(force_refresh)
|
|
|
|
@callback
|
|
def async_on_stop(self, sender, data):
|
|
"""Handle the stop of the player playback."""
|
|
# Prevent stop notifications which are sent after quit notification
|
|
if self._players is None:
|
|
return
|
|
|
|
self._players = []
|
|
self._properties = {}
|
|
self._item = {}
|
|
self._media_position_updated_at = None
|
|
self._media_position = None
|
|
self.async_schedule_update_ha_state()
|
|
|
|
@callback
|
|
def async_on_volume_changed(self, sender, data):
|
|
"""Handle the volume changes."""
|
|
self._app_properties["volume"] = data["volume"]
|
|
self._app_properties["muted"] = data["muted"]
|
|
self.async_schedule_update_ha_state()
|
|
|
|
@callback
|
|
def async_on_quit(self, sender, data):
|
|
"""Reset the player state on quit action."""
|
|
self._players = None
|
|
self._properties = {}
|
|
self._item = {}
|
|
self._app_properties = {}
|
|
self.hass.async_create_task(self._ws_server.close())
|
|
|
|
async def _get_players(self):
|
|
"""Return the active player objects or None."""
|
|
import jsonrpc_base
|
|
|
|
try:
|
|
return await self.server.Player.GetActivePlayers()
|
|
except jsonrpc_base.jsonrpc.TransportError:
|
|
if self._players is not None:
|
|
_LOGGER.info("Unable to fetch kodi data")
|
|
_LOGGER.debug("Unable to fetch kodi data", exc_info=True)
|
|
return None
|
|
|
|
@property
|
|
def unique_id(self):
|
|
"""Return the unique id of the device."""
|
|
return self._unique_id
|
|
|
|
@property
|
|
def state(self):
|
|
"""Return the state of the device."""
|
|
if self._players is None:
|
|
return STATE_OFF
|
|
|
|
if not self._players:
|
|
return STATE_IDLE
|
|
|
|
if self._properties["speed"] == 0:
|
|
return STATE_PAUSED
|
|
|
|
return STATE_PLAYING
|
|
|
|
async def async_ws_connect(self):
|
|
"""Connect to Kodi via websocket protocol."""
|
|
import jsonrpc_base
|
|
|
|
try:
|
|
ws_loop_future = await self._ws_server.ws_connect()
|
|
except jsonrpc_base.jsonrpc.TransportError:
|
|
_LOGGER.info("Unable to connect to Kodi via websocket")
|
|
_LOGGER.debug("Unable to connect to Kodi via websocket", exc_info=True)
|
|
return
|
|
|
|
async def ws_loop_wrapper():
|
|
"""Catch exceptions from the websocket loop task."""
|
|
try:
|
|
await ws_loop_future
|
|
except jsonrpc_base.TransportError:
|
|
# Kodi abruptly ends ws connection when exiting. We will try
|
|
# to reconnect on the next poll.
|
|
pass
|
|
# Update HA state after Kodi disconnects
|
|
self.async_schedule_update_ha_state()
|
|
|
|
# Create a task instead of adding a tracking job, since this task will
|
|
# run until the websocket connection is closed.
|
|
self.hass.loop.create_task(ws_loop_wrapper())
|
|
|
|
async def async_update(self):
|
|
"""Retrieve latest state."""
|
|
self._players = await self._get_players()
|
|
|
|
if self._players is None:
|
|
self._properties = {}
|
|
self._item = {}
|
|
self._app_properties = {}
|
|
return
|
|
|
|
if self._enable_websocket and not self._ws_server.connected:
|
|
self.hass.async_create_task(self.async_ws_connect())
|
|
|
|
self._app_properties = await self.server.Application.GetProperties(
|
|
["volume", "muted"]
|
|
)
|
|
|
|
if self._players:
|
|
player_id = self._players[0]["playerid"]
|
|
|
|
assert isinstance(player_id, int)
|
|
|
|
self._properties = await self.server.Player.GetProperties(
|
|
player_id, ["time", "totaltime", "speed", "live"]
|
|
)
|
|
|
|
position = self._properties["time"]
|
|
if self._media_position != position:
|
|
self._media_position_updated_at = dt_util.utcnow()
|
|
self._media_position = position
|
|
|
|
self._item = (
|
|
await self.server.Player.GetItem(
|
|
player_id,
|
|
[
|
|
"title",
|
|
"file",
|
|
"uniqueid",
|
|
"thumbnail",
|
|
"artist",
|
|
"albumartist",
|
|
"showtitle",
|
|
"album",
|
|
"season",
|
|
"episode",
|
|
],
|
|
)
|
|
)["item"]
|
|
else:
|
|
self._properties = {}
|
|
self._item = {}
|
|
self._app_properties = {}
|
|
self._media_position = None
|
|
self._media_position_updated_at = None
|
|
|
|
@property
|
|
def server(self):
|
|
"""Active server for json-rpc requests."""
|
|
if self._enable_websocket and self._ws_server.connected:
|
|
return self._ws_server
|
|
|
|
return self._http_server
|
|
|
|
@property
|
|
def name(self):
|
|
"""Return the name of the device."""
|
|
return self._name
|
|
|
|
@property
|
|
def should_poll(self):
|
|
"""Return True if entity has to be polled for state."""
|
|
return not (self._enable_websocket and self._ws_server.connected)
|
|
|
|
@property
|
|
def volume_level(self):
|
|
"""Volume level of the media player (0..1)."""
|
|
if "volume" in self._app_properties:
|
|
return self._app_properties["volume"] / 100.0
|
|
|
|
@property
|
|
def is_volume_muted(self):
|
|
"""Boolean if volume is currently muted."""
|
|
return self._app_properties.get("muted")
|
|
|
|
@property
|
|
def media_content_id(self):
|
|
"""Content ID of current playing media."""
|
|
return self._item.get("uniqueid", None)
|
|
|
|
@property
|
|
def media_content_type(self):
|
|
"""Content type of current playing media.
|
|
|
|
If the media type cannot be detected, the player type is used.
|
|
"""
|
|
if MEDIA_TYPES.get(self._item.get("type")) is None and self._players:
|
|
return MEDIA_TYPES.get(self._players[0]["type"])
|
|
return MEDIA_TYPES.get(self._item.get("type"))
|
|
|
|
@property
|
|
def media_duration(self):
|
|
"""Duration of current playing media in seconds."""
|
|
if self._properties.get("live"):
|
|
return None
|
|
|
|
total_time = self._properties.get("totaltime")
|
|
|
|
if total_time is None:
|
|
return None
|
|
|
|
return (
|
|
total_time["hours"] * 3600
|
|
+ total_time["minutes"] * 60
|
|
+ total_time["seconds"]
|
|
)
|
|
|
|
@property
|
|
def media_position(self):
|
|
"""Position of current playing media in seconds."""
|
|
time = self._properties.get("time")
|
|
|
|
if time is None:
|
|
return None
|
|
|
|
return time["hours"] * 3600 + time["minutes"] * 60 + time["seconds"]
|
|
|
|
@property
|
|
def media_position_updated_at(self):
|
|
"""Last valid time of media position."""
|
|
return self._media_position_updated_at
|
|
|
|
@property
|
|
def media_image_url(self):
|
|
"""Image url of current playing media."""
|
|
thumbnail = self._item.get("thumbnail")
|
|
if thumbnail is None:
|
|
return None
|
|
|
|
url_components = urllib.parse.urlparse(thumbnail)
|
|
if url_components.scheme == "image":
|
|
return "{}/{}".format(self._image_url, urllib.parse.quote_plus(thumbnail))
|
|
|
|
@property
|
|
def media_title(self):
|
|
"""Title of current playing media."""
|
|
# find a string we can use as a title
|
|
item = self._item
|
|
return item.get("title") or item.get("label") or item.get("file")
|
|
|
|
@property
|
|
def media_series_title(self):
|
|
"""Title of series of current playing media, TV show only."""
|
|
return self._item.get("showtitle")
|
|
|
|
@property
|
|
def media_season(self):
|
|
"""Season of current playing media, TV show only."""
|
|
return self._item.get("season")
|
|
|
|
@property
|
|
def media_episode(self):
|
|
"""Episode of current playing media, TV show only."""
|
|
return self._item.get("episode")
|
|
|
|
@property
|
|
def media_album_name(self):
|
|
"""Album name of current playing media, music track only."""
|
|
return self._item.get("album")
|
|
|
|
@property
|
|
def media_artist(self):
|
|
"""Artist of current playing media, music track only."""
|
|
artists = self._item.get("artist", [])
|
|
if artists:
|
|
return artists[0]
|
|
|
|
return None
|
|
|
|
@property
|
|
def media_album_artist(self):
|
|
"""Album artist of current playing media, music track only."""
|
|
artists = self._item.get("albumartist", [])
|
|
if artists:
|
|
return artists[0]
|
|
|
|
return None
|
|
|
|
@property
|
|
def supported_features(self):
|
|
"""Flag media player features that are supported."""
|
|
supported_features = SUPPORT_KODI
|
|
|
|
if self._turn_on_action is not None:
|
|
supported_features |= SUPPORT_TURN_ON
|
|
|
|
if self._turn_off_action is not None:
|
|
supported_features |= SUPPORT_TURN_OFF
|
|
|
|
return supported_features
|
|
|
|
@cmd
|
|
async def async_turn_on(self):
|
|
"""Execute turn_on_action to turn on media player."""
|
|
if self._turn_on_action is not None:
|
|
await self._turn_on_action.async_run(
|
|
variables={"entity_id": self.entity_id}
|
|
)
|
|
else:
|
|
_LOGGER.warning("turn_on requested but turn_on_action is none")
|
|
|
|
@cmd
|
|
async def async_turn_off(self):
|
|
"""Execute turn_off_action to turn off media player."""
|
|
if self._turn_off_action is not None:
|
|
await self._turn_off_action.async_run(
|
|
variables={"entity_id": self.entity_id}
|
|
)
|
|
else:
|
|
_LOGGER.warning("turn_off requested but turn_off_action is none")
|
|
|
|
@cmd
|
|
async def async_volume_up(self):
|
|
"""Volume up the media player."""
|
|
assert (await self.server.Input.ExecuteAction("volumeup")) == "OK"
|
|
|
|
@cmd
|
|
async def async_volume_down(self):
|
|
"""Volume down the media player."""
|
|
assert (await self.server.Input.ExecuteAction("volumedown")) == "OK"
|
|
|
|
@cmd
|
|
def async_set_volume_level(self, volume):
|
|
"""Set volume level, range 0..1.
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
"""
|
|
return self.server.Application.SetVolume(int(volume * 100))
|
|
|
|
@cmd
|
|
def async_mute_volume(self, mute):
|
|
"""Mute (true) or unmute (false) media player.
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
"""
|
|
return self.server.Application.SetMute(mute)
|
|
|
|
async def async_set_play_state(self, state):
|
|
"""Handle play/pause/toggle."""
|
|
players = await self._get_players()
|
|
|
|
if players is not None and players:
|
|
await self.server.Player.PlayPause(players[0]["playerid"], state)
|
|
|
|
@cmd
|
|
def async_media_play_pause(self):
|
|
"""Pause media on media player.
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
"""
|
|
return self.async_set_play_state("toggle")
|
|
|
|
@cmd
|
|
def async_media_play(self):
|
|
"""Play media.
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
"""
|
|
return self.async_set_play_state(True)
|
|
|
|
@cmd
|
|
def async_media_pause(self):
|
|
"""Pause the media player.
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
"""
|
|
return self.async_set_play_state(False)
|
|
|
|
@cmd
|
|
async def async_media_stop(self):
|
|
"""Stop the media player."""
|
|
players = await self._get_players()
|
|
|
|
if players:
|
|
await self.server.Player.Stop(players[0]["playerid"])
|
|
|
|
async def _goto(self, direction):
|
|
"""Handle for previous/next track."""
|
|
players = await self._get_players()
|
|
|
|
if players:
|
|
if direction == "previous":
|
|
# First seek to position 0. Kodi goes to the beginning of the
|
|
# current track if the current track is not at the beginning.
|
|
await self.server.Player.Seek(players[0]["playerid"], 0)
|
|
|
|
await self.server.Player.GoTo(players[0]["playerid"], direction)
|
|
|
|
@cmd
|
|
def async_media_next_track(self):
|
|
"""Send next track command.
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
"""
|
|
return self._goto("next")
|
|
|
|
@cmd
|
|
def async_media_previous_track(self):
|
|
"""Send next track command.
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
"""
|
|
return self._goto("previous")
|
|
|
|
@cmd
|
|
async def async_media_seek(self, position):
|
|
"""Send seek command."""
|
|
players = await self._get_players()
|
|
|
|
time = {}
|
|
|
|
time["milliseconds"] = int((position % 1) * 1000)
|
|
position = int(position)
|
|
|
|
time["seconds"] = int(position % 60)
|
|
position /= 60
|
|
|
|
time["minutes"] = int(position % 60)
|
|
position /= 60
|
|
|
|
time["hours"] = int(position)
|
|
|
|
if players:
|
|
await self.server.Player.Seek(players[0]["playerid"], time)
|
|
|
|
@cmd
|
|
def async_play_media(self, media_type, media_id, **kwargs):
|
|
"""Send the play_media command to the media player.
|
|
|
|
This method must be run in the event loop and returns a coroutine.
|
|
"""
|
|
if media_type == "CHANNEL":
|
|
return self.server.Player.Open({"item": {"channelid": int(media_id)}})
|
|
if media_type == "PLAYLIST":
|
|
return self.server.Player.Open({"item": {"playlistid": int(media_id)}})
|
|
|
|
return self.server.Player.Open({"item": {"file": str(media_id)}})
|
|
|
|
async def async_set_shuffle(self, shuffle):
|
|
"""Set shuffle mode, for the first player."""
|
|
if not self._players:
|
|
raise RuntimeError("Error: No active player.")
|
|
await self.server.Player.SetShuffle(
|
|
{"playerid": self._players[0]["playerid"], "shuffle": shuffle}
|
|
)
|
|
|
|
async def async_call_method(self, method, **kwargs):
|
|
"""Run Kodi JSONRPC API method with params."""
|
|
import jsonrpc_base
|
|
|
|
_LOGGER.debug("Run API method %s, kwargs=%s", method, kwargs)
|
|
result_ok = False
|
|
try:
|
|
result = await getattr(self.server, method)(**kwargs)
|
|
result_ok = True
|
|
except jsonrpc_base.jsonrpc.ProtocolError as exc:
|
|
result = exc.args[2]["error"]
|
|
_LOGGER.error(
|
|
"Run API method %s.%s(%s) error: %s",
|
|
self.entity_id,
|
|
method,
|
|
kwargs,
|
|
result,
|
|
)
|
|
except jsonrpc_base.jsonrpc.TransportError:
|
|
result = None
|
|
_LOGGER.warning(
|
|
"TransportError trying to run API method " "%s.%s(%s)",
|
|
self.entity_id,
|
|
method,
|
|
kwargs,
|
|
)
|
|
|
|
if isinstance(result, dict):
|
|
event_data = {
|
|
"entity_id": self.entity_id,
|
|
"result": result,
|
|
"result_ok": result_ok,
|
|
"input": {"method": method, "params": kwargs},
|
|
}
|
|
_LOGGER.debug("EVENT kodi_call_method_result: %s", event_data)
|
|
self.hass.bus.async_fire(
|
|
EVENT_KODI_CALL_METHOD_RESULT, event_data=event_data
|
|
)
|
|
return result
|
|
|
|
async def async_add_media_to_playlist(
|
|
self, media_type, media_id=None, media_name="ALL", artist_name=""
|
|
):
|
|
"""Add a media to default playlist (i.e. playlistid=0).
|
|
|
|
First the media type must be selected, then
|
|
the media can be specified in terms of id or
|
|
name and optionally artist name.
|
|
All the albums of an artist can be added with
|
|
media_name="ALL"
|
|
"""
|
|
import jsonrpc_base
|
|
|
|
params = {"playlistid": 0}
|
|
if media_type == "SONG":
|
|
if media_id is None:
|
|
media_id = await self.async_find_song(media_name, artist_name)
|
|
if media_id:
|
|
params["item"] = {"songid": int(media_id)}
|
|
|
|
elif media_type == "ALBUM":
|
|
if media_id is None:
|
|
if media_name == "ALL":
|
|
await self.async_add_all_albums(artist_name)
|
|
return
|
|
|
|
media_id = await self.async_find_album(media_name, artist_name)
|
|
if media_id:
|
|
params["item"] = {"albumid": int(media_id)}
|
|
|
|
else:
|
|
raise RuntimeError("Unrecognized media type.")
|
|
|
|
if media_id is not None:
|
|
try:
|
|
await self.server.Playlist.Add(params)
|
|
except jsonrpc_base.jsonrpc.ProtocolError as exc:
|
|
result = exc.args[2]["error"]
|
|
_LOGGER.error(
|
|
"Run API method %s.Playlist.Add(%s) error: %s",
|
|
self.entity_id,
|
|
media_type,
|
|
result,
|
|
)
|
|
except jsonrpc_base.jsonrpc.TransportError:
|
|
_LOGGER.warning(
|
|
"TransportError trying to add playlist to %s", self.entity_id
|
|
)
|
|
else:
|
|
_LOGGER.warning("No media detected for Playlist.Add")
|
|
|
|
async def async_add_all_albums(self, artist_name):
|
|
"""Add all albums of an artist to default playlist (i.e. playlistid=0).
|
|
|
|
The artist is specified in terms of name.
|
|
"""
|
|
artist_id = await self.async_find_artist(artist_name)
|
|
|
|
albums = await self.async_get_albums(artist_id)
|
|
|
|
for alb in albums["albums"]:
|
|
await self.server.Playlist.Add(
|
|
{"playlistid": 0, "item": {"albumid": int(alb["albumid"])}}
|
|
)
|
|
|
|
async def async_clear_playlist(self):
|
|
"""Clear default playlist (i.e. playlistid=0)."""
|
|
return self.server.Playlist.Clear({"playlistid": 0})
|
|
|
|
async def async_get_artists(self):
|
|
"""Get artists list."""
|
|
return await self.server.AudioLibrary.GetArtists()
|
|
|
|
async def async_get_albums(self, artist_id=None):
|
|
"""Get albums list."""
|
|
if artist_id is None:
|
|
return await self.server.AudioLibrary.GetAlbums()
|
|
|
|
return await self.server.AudioLibrary.GetAlbums(
|
|
{"filter": {"artistid": int(artist_id)}}
|
|
)
|
|
|
|
async def async_find_artist(self, artist_name):
|
|
"""Find artist by name."""
|
|
artists = await self.async_get_artists()
|
|
try:
|
|
out = self._find(artist_name, [a["artist"] for a in artists["artists"]])
|
|
return artists["artists"][out[0][0]]["artistid"]
|
|
except KeyError:
|
|
_LOGGER.warning("No artists were found: %s", artist_name)
|
|
return None
|
|
|
|
async def async_get_songs(self, artist_id=None):
|
|
"""Get songs list."""
|
|
if artist_id is None:
|
|
return await self.server.AudioLibrary.GetSongs()
|
|
|
|
return await self.server.AudioLibrary.GetSongs(
|
|
{"filter": {"artistid": int(artist_id)}}
|
|
)
|
|
|
|
async def async_find_song(self, song_name, artist_name=""):
|
|
"""Find song by name and optionally artist name."""
|
|
artist_id = None
|
|
if artist_name != "":
|
|
artist_id = await self.async_find_artist(artist_name)
|
|
|
|
songs = await self.async_get_songs(artist_id)
|
|
if songs["limits"]["total"] == 0:
|
|
return None
|
|
|
|
out = self._find(song_name, [a["label"] for a in songs["songs"]])
|
|
return songs["songs"][out[0][0]]["songid"]
|
|
|
|
async def async_find_album(self, album_name, artist_name=""):
|
|
"""Find album by name and optionally artist name."""
|
|
artist_id = None
|
|
if artist_name != "":
|
|
artist_id = await self.async_find_artist(artist_name)
|
|
|
|
albums = await self.async_get_albums(artist_id)
|
|
try:
|
|
out = self._find(album_name, [a["label"] for a in albums["albums"]])
|
|
return albums["albums"][out[0][0]]["albumid"]
|
|
except KeyError:
|
|
_LOGGER.warning(
|
|
"No albums were found with artist: %s, album: %s",
|
|
artist_name,
|
|
album_name,
|
|
)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _find(key_word, words):
|
|
key_word = key_word.split(" ")
|
|
patt = [re.compile("(^| )" + k + "( |$)", re.IGNORECASE) for k in key_word]
|
|
|
|
out = [[i, 0] for i in range(len(words))]
|
|
for i in range(len(words)):
|
|
mtc = [p.search(words[i]) for p in patt]
|
|
rate = [m is not None for m in mtc].count(True)
|
|
out[i][1] = rate
|
|
|
|
return sorted(out, key=lambda out: out[1], reverse=True)
|