mirror of
https://github.com/sudoxnym/fin-assistant.git
synced 2026-04-14 11:37:38 +00:00
Initial baseline
This commit is contained in:
parent
fce57a871a
commit
e73f03f697
8 changed files with 1306 additions and 0 deletions
445
__init__.py
Normal file
445
__init__.py
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
"""The jellyfin component."""
|
||||
import logging
|
||||
import time
|
||||
import re
|
||||
import traceback
|
||||
import collections.abc
|
||||
from typing import Mapping, MutableMapping, Sequence, Iterable, List
|
||||
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from jellyfin_apiclient_python import JellyfinClient
|
||||
from jellyfin_apiclient_python.connection_manager import CONNECTION_STATE
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ( # pylint: disable=import-error
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_VERIFY_SSL,
|
||||
CONF_CLIENT_ID,
|
||||
)
|
||||
import homeassistant.helpers.config_validation as cv # pylint: disable=import-error
|
||||
|
||||
from homeassistant.helpers.dispatcher import ( # pylint: disable=import-error
|
||||
async_dispatcher_send,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
SIGNAL_STATE_UPDATED,
|
||||
STATE_OFF,
|
||||
STATE_IDLE,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
)
|
||||
from .device import JellyfinDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = ["media_player"]
|
||||
UPDATE_UNLISTENER = None
|
||||
|
||||
USER_APP_NAME = "Home Assistant"
|
||||
CLIENT_VERSION = "1.0"
|
||||
PATH_REGEX = re.compile("^(https?://)?([^/:]+)(:[0-9]+)?(/.*)?$")
|
||||
|
||||
def autolog(message):
|
||||
"Automatically log the current function details."
|
||||
import inspect
|
||||
# Get the previous frame in the stack, otherwise it would
|
||||
# be this function!!!
|
||||
func = inspect.currentframe().f_back.f_code
|
||||
# Dump the message + the name of this function to the log.
|
||||
_LOGGER.debug("%s: %s in %s:%i" % (
|
||||
message,
|
||||
func.co_name,
|
||||
func.co_filename,
|
||||
func.co_firstlineno
|
||||
))
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: dict):
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = {}
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry):
|
||||
global UPDATE_UNLISTENER
|
||||
if UPDATE_UNLISTENER:
|
||||
UPDATE_UNLISTENER()
|
||||
|
||||
if not config_entry.unique_id:
|
||||
hass.config_entries.async_update_entry(
|
||||
config_entry, unique_id=config_entry.title
|
||||
)
|
||||
|
||||
config = {}
|
||||
for key, value in config_entry.data.items():
|
||||
config[key] = value
|
||||
for key, value in config_entry.options.items():
|
||||
config[key] = value
|
||||
if config_entry.options:
|
||||
hass.config_entries.async_update_entry(config_entry, data=config, options={})
|
||||
|
||||
UPDATE_UNLISTENER = config_entry.add_update_listener(_update_listener)
|
||||
|
||||
_jelly = JellyfinClientManager(hass, config)
|
||||
try:
|
||||
await _jelly.connect()
|
||||
hass.data[DOMAIN][config.get(CONF_HOST)] = _jelly
|
||||
except:
|
||||
_LOGGER.error("Cannot connect to Jellyfin server.")
|
||||
raise
|
||||
|
||||
for component in PLATFORMS:
|
||||
hass.async_create_task(
|
||||
hass.config_entries.async_forward_entry_setup(config_entry, component)
|
||||
)
|
||||
|
||||
async_dispatcher_send(hass, SIGNAL_STATE_UPDATED)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _update_listener(hass, config_entry):
|
||||
"""Update listener."""
|
||||
await hass.config_entries.async_reload(config_entry.entry_id)
|
||||
|
||||
|
||||
class JellyfinClientManager(object):
|
||||
def __init__(self, hass: HomeAssistant, config_entry):
|
||||
self.hass = hass
|
||||
self.callback = lambda client, event_name, data: None
|
||||
self.client: JellyfinClient = None
|
||||
self.is_stopping = True
|
||||
self._event_loop = hass.loop
|
||||
self.config_entry = config_entry
|
||||
self.server_url = ""
|
||||
|
||||
self._sessions = None
|
||||
self._devices: Mapping[str, JellyfinDevice] = {}
|
||||
|
||||
# Callbacks
|
||||
self._new_devices_callbacks = []
|
||||
self._stale_devices_callbacks = []
|
||||
self._update_callbacks = []
|
||||
|
||||
@staticmethod
|
||||
def expo(max_value = None):
|
||||
n = 0
|
||||
while True:
|
||||
a = 2 ** n
|
||||
if max_value is None or a < max_value:
|
||||
yield a
|
||||
n += 1
|
||||
else:
|
||||
yield max_value
|
||||
|
||||
@staticmethod
|
||||
def clean_none_dict_values(obj):
|
||||
"""
|
||||
Recursively remove keys with a value of None
|
||||
"""
|
||||
if not isinstance(obj, collections.abc.Iterable) or isinstance(obj, str):
|
||||
return obj
|
||||
|
||||
queue = [obj]
|
||||
|
||||
while queue:
|
||||
item = queue.pop()
|
||||
|
||||
if isinstance(item, collections.abc.Mapping):
|
||||
mutable = isinstance(item, collections.abc.MutableMapping)
|
||||
remove = []
|
||||
|
||||
for key, value in item.items():
|
||||
if value is None and mutable:
|
||||
remove.append(key)
|
||||
|
||||
elif isinstance(value, str):
|
||||
continue
|
||||
|
||||
elif isinstance(value, collections.abc.Iterable):
|
||||
queue.append(value)
|
||||
|
||||
if mutable:
|
||||
# Remove keys with None value
|
||||
for key in remove:
|
||||
item.pop(key)
|
||||
|
||||
elif isinstance(item, collections.abc.Iterable):
|
||||
for value in item:
|
||||
if value is None or isinstance(value, str):
|
||||
continue
|
||||
elif isinstance(value, collections.abc.Iterable):
|
||||
queue.append(value)
|
||||
|
||||
return obj
|
||||
|
||||
async def connect(self):
|
||||
autolog(">>>")
|
||||
|
||||
is_logged_in = await self.hass.async_add_executor_job(self.login)
|
||||
|
||||
if is_logged_in:
|
||||
_LOGGER.info("Successfully added server.")
|
||||
else:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
@staticmethod
|
||||
def client_factory(config_entry):
|
||||
client = JellyfinClient(allow_multiple_clients=True)
|
||||
client.config.data["app.default"] = True
|
||||
client.config.app(
|
||||
USER_APP_NAME, CLIENT_VERSION, USER_APP_NAME, config_entry[CONF_CLIENT_ID]
|
||||
)
|
||||
client.config.data["auth.ssl"] = config_entry[CONF_VERIFY_SSL]
|
||||
return client
|
||||
|
||||
def login(self):
|
||||
autolog(">>>")
|
||||
|
||||
self.server_url = self.config_entry[CONF_URL]
|
||||
|
||||
if self.server_url.endswith("/"):
|
||||
self.server_url = self.server_url[:-1]
|
||||
|
||||
protocol, host, port, path = PATH_REGEX.match(self.server_url).groups()
|
||||
|
||||
if not protocol:
|
||||
_LOGGER.warning("Adding http:// because it was not provided.")
|
||||
protocol = "http://"
|
||||
|
||||
if protocol == "http://" and not port:
|
||||
_LOGGER.warning("Adding port 8096 for insecure local http connection.")
|
||||
_LOGGER.warning(
|
||||
"If you want to connect to standard http port 80, use :80 in the url."
|
||||
)
|
||||
port = ":8096"
|
||||
|
||||
if protocol == "https://" and not port:
|
||||
port = ":443"
|
||||
|
||||
self.server_url = "".join(filter(bool, (protocol, host, port, path)))
|
||||
|
||||
self.client = self.client_factory(self.config_entry)
|
||||
self.client.auth.connect_to_address(self.server_url)
|
||||
result = self.client.auth.login(self.server_url, self.config_entry[CONF_USERNAME], self.config_entry[CONF_PASSWORD])
|
||||
if "AccessToken" not in result:
|
||||
return False
|
||||
|
||||
credentials = self.client.auth.credentials.get_credentials()
|
||||
self.client.authenticate(credentials)
|
||||
return True
|
||||
|
||||
async def start(self):
|
||||
autolog(">>>")
|
||||
|
||||
def event(event_name, data):
|
||||
_LOGGER.debug("Event: %s", event_name)
|
||||
if event_name == "WebSocketConnect":
|
||||
self.client.wsc.send("SessionsStart", "0,1500")
|
||||
elif event_name == "WebSocketDisconnect":
|
||||
timeout_gen = self.expo(100)
|
||||
while not self.is_stopping:
|
||||
timeout = next(timeout_gen)
|
||||
_LOGGER.warning(
|
||||
"No connection to server. Next try in {0} second(s)".format(
|
||||
timeout
|
||||
)
|
||||
)
|
||||
self.client.stop()
|
||||
time.sleep(timeout)
|
||||
if self.login():
|
||||
break
|
||||
elif event_name == "Sessions":
|
||||
self._sessions = self.clean_none_dict_values(data)["value"]
|
||||
self.update_device_list(self._sessions)
|
||||
else:
|
||||
self.callback(self.client, event_name, data)
|
||||
|
||||
self.client.callback = event
|
||||
self.client.callback_ws = event
|
||||
|
||||
await self.hass.async_add_executor_job(self.client.start, True)
|
||||
|
||||
self.is_stopping = False
|
||||
|
||||
self._sessions = self.clean_none_dict_values(await self.hass.async_add_executor_job(self.client.jellyfin.get_sessions))
|
||||
self.update_device_list(self._sessions)
|
||||
|
||||
async def stop(self):
|
||||
autolog(">>>")
|
||||
|
||||
await self.hass.async_add_executor_job(self.client.stop)
|
||||
self.is_stopping = True
|
||||
|
||||
def update_device_list(self, sessions):
|
||||
""" Update device list. """
|
||||
autolog(">>>")
|
||||
_LOGGER.debug("sessions: %s", str(sessions))
|
||||
if sessions is None:
|
||||
_LOGGER.error('Error updating Jellyfin devices.')
|
||||
return
|
||||
|
||||
try:
|
||||
new_devices = []
|
||||
active_devices = []
|
||||
dev_update = False
|
||||
for device in sessions:
|
||||
_LOGGER.debug("device: %s", str(device))
|
||||
dev_name = '{}.{}'.format(device['DeviceId'], device['Client'])
|
||||
|
||||
try:
|
||||
_LOGGER.debug('Session msg on %s of type: %s, themeflag: %s',
|
||||
dev_name, device['NowPlayingItem']['Type'],
|
||||
device['NowPlayingItem']['IsThemeMedia'])
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
active_devices.append(dev_name)
|
||||
if dev_name not in self._devices and \
|
||||
device['DeviceId'] != self.config_entry[CONF_CLIENT_ID]:
|
||||
_LOGGER.debug('New Jellyfin DeviceID: %s. Adding to device list.',
|
||||
dev_name)
|
||||
new = JellyfinDevice(device, self)
|
||||
self._devices[dev_name] = new
|
||||
new_devices.append(new)
|
||||
elif device['DeviceId'] != self.config_entry[CONF_CLIENT_ID]:
|
||||
# Before we send in new data check for changes to state
|
||||
# to decide if we need to fire the update callback
|
||||
if not self._devices[dev_name].is_active:
|
||||
# Device wasn't active on the last update
|
||||
# We need to fire a device callback to let subs now
|
||||
dev_update = True
|
||||
|
||||
do_update = self.update_check(
|
||||
self._devices[dev_name], device)
|
||||
self._devices[dev_name].update_data(device)
|
||||
self._devices[dev_name].set_active(True)
|
||||
if dev_update:
|
||||
self._do_new_devices_callback(0)
|
||||
dev_update = False
|
||||
if do_update:
|
||||
self._do_update_callback(dev_name)
|
||||
|
||||
# Need to check for new inactive devices and flag
|
||||
for dev_id in self._devices:
|
||||
if dev_id not in active_devices:
|
||||
# Device no longer active
|
||||
if self._devices[dev_id].is_active:
|
||||
self._devices[dev_id].set_active(False)
|
||||
self._do_update_callback(dev_id)
|
||||
self._do_stale_devices_callback(dev_id)
|
||||
|
||||
# Call device callback if new devices were found.
|
||||
if new_devices:
|
||||
self._do_new_devices_callback(0)
|
||||
except Exception as e:
|
||||
_LOGGER.critical(traceback.format_exc())
|
||||
raise
|
||||
|
||||
def update_check(self, existing: JellyfinDevice, new: JellyfinDevice):
|
||||
""" Check device state to see if we need to fire the callback.
|
||||
True if either state is 'Playing'
|
||||
False if both states are: 'Paused', 'Idle', or 'Off'
|
||||
True on any state transition.
|
||||
"""
|
||||
autolog(">>>")
|
||||
|
||||
old_state = existing.state
|
||||
if 'NowPlayingItem' in existing.session_raw:
|
||||
try:
|
||||
old_theme = existing.session_raw['NowPlayingItem']['IsThemeMedia']
|
||||
except KeyError:
|
||||
old_theme = False
|
||||
else:
|
||||
old_theme = False
|
||||
|
||||
if 'NowPlayingItem' in new:
|
||||
if new['PlayState']['IsPaused']:
|
||||
new_state = STATE_PAUSED
|
||||
else:
|
||||
new_state = STATE_PLAYING
|
||||
|
||||
try:
|
||||
new_theme = new['NowPlayingItem']['IsThemeMedia']
|
||||
except KeyError:
|
||||
new_theme = False
|
||||
|
||||
else:
|
||||
new_state = STATE_IDLE
|
||||
new_theme = False
|
||||
|
||||
if old_theme or new_theme:
|
||||
return False
|
||||
elif old_state == STATE_PLAYING or new_state == STATE_PLAYING:
|
||||
return True
|
||||
elif old_state != new_state:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
async def set_playstate(self, session_id, state, params):
|
||||
await self.hass.async_add_executor_job(self.client.jellyfin.post_session, session_id, "Playing/%s" % state, params)
|
||||
|
||||
@property
|
||||
def api(self):
|
||||
""" Return the api. """
|
||||
return self.client.jellyfin
|
||||
|
||||
@property
|
||||
def devices(self) -> Mapping[str, JellyfinDevice]:
|
||||
""" Return devices dictionary. """
|
||||
return self._devices
|
||||
|
||||
# Callbacks
|
||||
|
||||
def add_new_devices_callback(self, callback):
|
||||
"""Register as callback for when new devices are added. """
|
||||
self._new_devices_callbacks.append(callback)
|
||||
_LOGGER.debug('Added new devices callback to %s', callback)
|
||||
|
||||
def _do_new_devices_callback(self, msg):
|
||||
"""Call registered callback functions."""
|
||||
for callback in self._new_devices_callbacks:
|
||||
_LOGGER.debug('Devices callback %s', callback)
|
||||
self._event_loop.call_soon(callback, msg)
|
||||
|
||||
def add_stale_devices_callback(self, callback):
|
||||
"""Register as callback for when stale devices exist. """
|
||||
self._stale_devices_callbacks.append(callback)
|
||||
_LOGGER.debug('Added stale devices callback to %s', callback)
|
||||
|
||||
def _do_stale_devices_callback(self, msg):
|
||||
"""Call registered callback functions."""
|
||||
for callback in self._stale_devices_callbacks:
|
||||
_LOGGER.debug('Stale Devices callback %s', callback)
|
||||
self._event_loop.call_soon(callback, msg)
|
||||
|
||||
def add_update_callback(self, callback, device):
|
||||
"""Register as callback for when a matching device changes."""
|
||||
self._update_callbacks.append([callback, device])
|
||||
_LOGGER.debug('Added update callback to %s on %s', callback, device)
|
||||
|
||||
def remove_update_callback(self, callback, device):
|
||||
""" Remove a registered update callback. """
|
||||
if [callback, device] in self._update_callbacks:
|
||||
self._update_callbacks.remove([callback, device])
|
||||
_LOGGER.debug('Removed update callback %s for %s',
|
||||
callback, device)
|
||||
|
||||
def _do_update_callback(self, msg):
|
||||
"""Call registered callback functions."""
|
||||
for callback, device in self._update_callbacks:
|
||||
if device == msg:
|
||||
_LOGGER.debug('Update callback %s for device %s by %s',
|
||||
callback, device, msg)
|
||||
self._event_loop.call_soon(callback, msg)
|
||||
|
||||
|
||||
164
config_flow.py
Normal file
164
config_flow.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""Config flow for Jellyfin."""
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries, exceptions
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import ( # pylint: disable=import-error
|
||||
CONF_URL,
|
||||
CONF_VERIFY_SSL,
|
||||
CONF_USERNAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_CLIENT_ID,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
DEFAULT_SSL,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
DEFAULT_PORT,
|
||||
CONN_TIMEOUT,
|
||||
)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
RESULT_CONN_ERROR = "cannot_connect"
|
||||
RESULT_LOG_MESSAGE = {RESULT_CONN_ERROR: "Connection error"}
|
||||
|
||||
|
||||
@config_entries.HANDLERS.register(DOMAIN)
|
||||
class JellyfinFlowHandler(config_entries.ConfigFlow):
|
||||
"""Config flow for Jellyfin component."""
|
||||
|
||||
VERSION = 1
|
||||
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(config_entry):
|
||||
"""Jellyfin options callback."""
|
||||
return JellyfinOptionsFlowHandler(config_entry)
|
||||
|
||||
def __init__(self):
|
||||
"""Init JellyfinFlowHandler."""
|
||||
self._errors = {}
|
||||
self._url = None
|
||||
self._ssl = DEFAULT_SSL
|
||||
self._verify_ssl = DEFAULT_VERIFY_SSL
|
||||
|
||||
async def async_step_import(self, user_input=None):
|
||||
"""Handle configuration by yaml file."""
|
||||
self._is_import = True
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
"""Handle a flow initialized by the user."""
|
||||
|
||||
self._errors = {}
|
||||
|
||||
data_schema = {
|
||||
vol.Required(CONF_URL): str,
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
|
||||
}
|
||||
|
||||
if user_input is not None:
|
||||
self._url = str(user_input[CONF_URL])
|
||||
self._username = user_input[CONF_USERNAME]
|
||||
self._password = user_input[CONF_PASSWORD]
|
||||
self._verify_ssl = user_input[CONF_VERIFY_SSL]
|
||||
|
||||
try:
|
||||
await self.async_set_unique_id(DOMAIN)
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=DOMAIN,
|
||||
data={
|
||||
CONF_URL: self._url,
|
||||
CONF_USERNAME: self._username,
|
||||
CONF_PASSWORD: self._password,
|
||||
CONF_VERIFY_SSL: self._verify_ssl,
|
||||
CONF_CLIENT_ID: str(uuid.uuid4()),
|
||||
},
|
||||
)
|
||||
|
||||
except (asyncio.TimeoutError, CannotConnect):
|
||||
result = RESULT_CONN_ERROR
|
||||
|
||||
if self._is_import:
|
||||
_LOGGER.error(
|
||||
"Error importing from configuration.yaml: %s",
|
||||
RESULT_LOG_MESSAGE.get(result, "Generic Error"),
|
||||
)
|
||||
return self.async_abort(reason=result)
|
||||
|
||||
self._errors["base"] = result
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(data_schema),
|
||||
errors=self._errors,
|
||||
)
|
||||
|
||||
|
||||
class JellyfinOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
"""Option flow for Jellyfin component."""
|
||||
|
||||
def __init__(self, config_entry):
|
||||
"""Init JellyfinOptionsFlowHandler."""
|
||||
self._errors = {}
|
||||
self._url = config_entry.data[CONF_URL] if CONF_URL in config_entry.data else None
|
||||
self._username = config_entry.data[CONF_USERNAME] if CONF_USERNAME in config_entry.data else None
|
||||
self._password = config_entry.data[CONF_PASSWORD] if CONF_PASSWORD in config_entry.data else None
|
||||
self._verify_ssl = config_entry.data[CONF_VERIFY_SSL] if CONF_VERIFY_SSL in config_entry.options else DEFAULT_VERIFY_SSL
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Manage the options."""
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
self._errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._url = str(user_input[CONF_URL])
|
||||
self._username = user_input[CONF_USERNAME]
|
||||
self._password = user_input[CONF_PASSWORD]
|
||||
self._verify_ssl = user_input[CONF_VERIFY_SSL]
|
||||
|
||||
data_schema = {
|
||||
vol.Required(CONF_URL, default=self._url): str,
|
||||
vol.Required(CONF_USERNAME, default=self._username): str,
|
||||
vol.Required(CONF_PASSWORD, default=self._password): str,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=self._verify_ssl): bool,
|
||||
}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
return self.async_create_entry(
|
||||
title=DOMAIN,
|
||||
data={
|
||||
CONF_URL: self._url,
|
||||
CONF_USERNAME: self._username,
|
||||
CONF_PASSWORD: self._password,
|
||||
CONF_VERIFY_SSL: self._verify_ssl,
|
||||
},
|
||||
)
|
||||
|
||||
except (asyncio.TimeoutError, CannotConnect):
|
||||
_LOGGER.error("cannot connect")
|
||||
result = RESULT_CONN_ERROR
|
||||
|
||||
self._errors["base"] = result
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(data_schema),
|
||||
errors=self._errors,
|
||||
)
|
||||
|
||||
class CannotConnect(exceptions.HomeAssistantError):
|
||||
"""Error to indicate we can not connect."""
|
||||
15
const.py
Normal file
15
const.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"""Constants for the jellyfin integration."""
|
||||
|
||||
DOMAIN = "jellyfin"
|
||||
SIGNAL_STATE_UPDATED = "{}.updated".format(DOMAIN)
|
||||
|
||||
DEFAULT_SSL = False
|
||||
DEFAULT_VERIFY_SSL = True
|
||||
DEFAULT_PORT = 8096
|
||||
|
||||
CONN_TIMEOUT = 5.0
|
||||
|
||||
STATE_PLAYING = 'Playing'
|
||||
STATE_PAUSED = 'Paused'
|
||||
STATE_IDLE = 'Idle'
|
||||
STATE_OFF = 'Off'
|
||||
251
device.py
Normal file
251
device.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
import logging
|
||||
from .const import (
|
||||
STATE_OFF,
|
||||
STATE_IDLE,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
class JellyfinDevice(object):
|
||||
""" Represents properties of an Emby Device. """
|
||||
def __init__(self, session, server):
|
||||
"""Initialize Emby device object."""
|
||||
self.server = server
|
||||
self.is_active = True
|
||||
self.update_data(session)
|
||||
|
||||
def update_data(self, session):
|
||||
""" Update session object. """
|
||||
self.session = session
|
||||
|
||||
def set_active(self, active):
|
||||
""" Mark device as on/off. """
|
||||
self.is_active = active
|
||||
|
||||
@property
|
||||
def session_raw(self):
|
||||
""" Return raw session data. """
|
||||
return self.session
|
||||
|
||||
@property
|
||||
def session_id(self):
|
||||
""" Return current session Id. """
|
||||
try:
|
||||
return self.session['Id']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
""" Return device id."""
|
||||
try:
|
||||
return self.session['DeviceId']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
""" Return device name."""
|
||||
try:
|
||||
return self.session['DeviceName']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def client(self):
|
||||
""" Return client name. """
|
||||
try:
|
||||
return self.session['Client']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def username(self):
|
||||
""" Return device name."""
|
||||
try:
|
||||
return self.session['UserName']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
""" Return title currently playing."""
|
||||
try:
|
||||
return self.session['NowPlayingItem']['Name']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_season(self):
|
||||
"""Season of curent playing media (TV Show only)."""
|
||||
try:
|
||||
return self.session['NowPlayingItem']['ParentIndexNumber']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_series_title(self):
|
||||
"""The title of the series of current playing media (TV Show only)."""
|
||||
try:
|
||||
return self.session['NowPlayingItem']['SeriesName']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_episode(self):
|
||||
"""Episode of current playing media (TV Show only)."""
|
||||
try:
|
||||
return self.session['NowPlayingItem']['IndexNumber']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_album_name(self):
|
||||
"""Album name of current playing media (Music track only)."""
|
||||
try:
|
||||
return self.session['NowPlayingItem']['Album']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_artist(self):
|
||||
"""Artist of current playing media (Music track only)."""
|
||||
try:
|
||||
artists = self.session['NowPlayingItem']['Artists']
|
||||
if len(artists) > 1:
|
||||
return artists[0]
|
||||
else:
|
||||
return artists
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_album_artist(self):
|
||||
"""Album artist of current playing media (Music track only)."""
|
||||
try:
|
||||
return self.session['NowPlayingItem']['AlbumArtist']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_id(self):
|
||||
""" Return title currently playing."""
|
||||
try:
|
||||
return self.session['NowPlayingItem']['Id']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_type(self):
|
||||
""" Return type currently playing."""
|
||||
try:
|
||||
return self.session['NowPlayingItem']['Type']
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
"""Image url of current playing media."""
|
||||
if self.is_nowplaying:
|
||||
try:
|
||||
image_id = self.session['NowPlayingItem']['ImageTags']['Thumb']
|
||||
image_type = 'Thumb'
|
||||
except KeyError:
|
||||
try:
|
||||
image_id = self.session[
|
||||
'NowPlayingItem']['ImageTags']['Primary']
|
||||
image_type = 'Primary'
|
||||
except KeyError:
|
||||
return None
|
||||
url = self.server.api.artwork(self.media_id, image_type, 500)
|
||||
return url
|
||||
else:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_position(self):
|
||||
""" Return position currently playing."""
|
||||
try:
|
||||
return int(self.session['PlayState']['PositionTicks']) / 10000000
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_runtime(self):
|
||||
""" Return total runtime length."""
|
||||
try:
|
||||
return int(
|
||||
self.session['NowPlayingItem']['RunTimeTicks']) / 10000000
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_percent_played(self):
|
||||
""" Return media percent played. """
|
||||
try:
|
||||
return (self.media_position / self.media_runtime) * 100
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
""" Return current playstate of the device. """
|
||||
if self.is_active:
|
||||
if 'NowPlayingItem' in self.session:
|
||||
if self.session['PlayState']['IsPaused']:
|
||||
return STATE_PAUSED
|
||||
else:
|
||||
return STATE_PLAYING
|
||||
else:
|
||||
return STATE_IDLE
|
||||
else:
|
||||
return STATE_OFF
|
||||
|
||||
@property
|
||||
def is_nowplaying(self):
|
||||
""" Return true if an item is currently active. """
|
||||
if self.state == 'Idle' or self.state == 'Off':
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
@property
|
||||
def supports_remote_control(self):
|
||||
""" Return remote control status. """
|
||||
return self.session['SupportsRemoteControl']
|
||||
|
||||
async def set_playstate(self, state, pos=0):
|
||||
""" Send media commands to server. """
|
||||
|
||||
params = {}
|
||||
if state == 'Seek':
|
||||
params['SeekPositionTicks'] = int(pos * 10000000)
|
||||
params['static'] = 'true'
|
||||
|
||||
await self.server.set_playstate(self.session_id, state, params)
|
||||
|
||||
def media_play(self):
|
||||
""" Send play command to device. """
|
||||
return self.set_playstate('Unpause')
|
||||
|
||||
def media_pause(self):
|
||||
""" Send pause command to device. """
|
||||
return self.set_playstate('Pause')
|
||||
|
||||
def media_stop(self):
|
||||
""" Send stop command to device. """
|
||||
return self.set_playstate('Stop')
|
||||
|
||||
def media_next(self):
|
||||
""" Send next track command to device. """
|
||||
return self.set_playstate('NextTrack')
|
||||
|
||||
def media_previous(self):
|
||||
""" Send previous track command to device. """
|
||||
return self.set_playstate('PreviousTrack')
|
||||
|
||||
def media_seek(self, position):
|
||||
""" Send seek command to device. """
|
||||
return self.set_playstate('Seek', position)
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"domain": "jellyfin",
|
||||
"name": "Jellyfin",
|
||||
"version": "1.0",
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/emby",
|
||||
"requirements": ["jellyfin-apiclient-python==1.7.2"],
|
||||
"codeowners": ["@koying"],
|
||||
"iot_class": "local_push"
|
||||
}
|
||||
323
media_player.py
Normal file
323
media_player.py
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
"""Support to interface with the Jellyfin API."""
|
||||
import logging
|
||||
from typing import Mapping, MutableMapping, Sequence, Iterable, List
|
||||
|
||||
from jellyfin_apiclient_python import JellyfinClient
|
||||
from jellyfin_apiclient_python.connection_manager import CONNECTION_STATE
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
|
||||
from homeassistant.components.media_player.const import (
|
||||
MEDIA_TYPE_CHANNEL,
|
||||
MEDIA_TYPE_MOVIE,
|
||||
MEDIA_TYPE_MUSIC,
|
||||
MEDIA_TYPE_TVSHOW,
|
||||
SUPPORT_NEXT_TRACK,
|
||||
SUPPORT_PAUSE,
|
||||
SUPPORT_PLAY,
|
||||
SUPPORT_PREVIOUS_TRACK,
|
||||
SUPPORT_SEEK,
|
||||
SUPPORT_STOP,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_HOST,
|
||||
CONF_PORT,
|
||||
CONF_SSL,
|
||||
DEVICE_DEFAULT_NAME,
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
STATE_IDLE,
|
||||
STATE_OFF,
|
||||
STATE_PAUSED,
|
||||
STATE_PLAYING,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from . import JellyfinClientManager
|
||||
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
SIGNAL_STATE_UPDATED,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
MEDIA_TYPE_TRAILER = "trailer"
|
||||
MEDIA_TYPE_GENERIC_VIDEO = "video"
|
||||
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_PORT = 8096
|
||||
DEFAULT_SSL_PORT = 8920
|
||||
DEFAULT_SSL = False
|
||||
|
||||
SUPPORT_EMBY = (
|
||||
SUPPORT_PAUSE
|
||||
| SUPPORT_PREVIOUS_TRACK
|
||||
| SUPPORT_NEXT_TRACK
|
||||
| SUPPORT_STOP
|
||||
| SUPPORT_SEEK
|
||||
| SUPPORT_PLAY
|
||||
)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_API_KEY): cv.string,
|
||||
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities):
|
||||
"""Set up switches dynamically."""
|
||||
|
||||
active_jellyfin_devices: List[JellyfinMediaPlayer] = {}
|
||||
inactive_jellyfin_devices: List[JellyfinMediaPlayer] = {}
|
||||
|
||||
_jelly: JellyfinClientManager = hass.data[DOMAIN][config_entry.data.get(CONF_HOST)]
|
||||
|
||||
@callback
|
||||
def device_update_callback(data):
|
||||
"""Handle devices which are added to Jellyfin."""
|
||||
new_devices = []
|
||||
active_devices = []
|
||||
for dev_id in _jelly.devices:
|
||||
active_devices.append(dev_id)
|
||||
if (
|
||||
dev_id not in active_jellyfin_devices
|
||||
and dev_id not in inactive_jellyfin_devices
|
||||
):
|
||||
new = JellyfinMediaPlayer(_jelly, dev_id)
|
||||
active_jellyfin_devices[dev_id] = new
|
||||
new_devices.append(new)
|
||||
|
||||
elif (
|
||||
dev_id in inactive_jellyfin_devices and _jelly.devices[dev_id].state != "Off"
|
||||
):
|
||||
add = inactive_jellyfin_devices.pop(dev_id)
|
||||
active_jellyfin_devices[dev_id] = add
|
||||
_LOGGER.debug("Showing %s, item: %s", dev_id, add)
|
||||
add.set_available(True)
|
||||
|
||||
if new_devices:
|
||||
_LOGGER.debug("Adding new devices: %s", new_devices)
|
||||
async_add_entities(new_devices, True)
|
||||
|
||||
@callback
|
||||
def device_removal_callback(data):
|
||||
"""Handle the removal of devices from Jellyfin."""
|
||||
if data in active_jellyfin_devices:
|
||||
rem = active_jellyfin_devices.pop(data)
|
||||
inactive_jellyfin_devices[data] = rem
|
||||
_LOGGER.debug("Inactive %s, item: %s", data, rem)
|
||||
rem.set_available(False)
|
||||
|
||||
async def stop_jellyfin(event):
|
||||
"""Stop Jellyfin connection."""
|
||||
await _jelly.stop()
|
||||
|
||||
_jelly.add_new_devices_callback(device_update_callback)
|
||||
_jelly.add_stale_devices_callback(device_removal_callback)
|
||||
await _jelly.start()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_jellyfin)
|
||||
|
||||
class JellyfinMediaPlayer(MediaPlayerEntity):
|
||||
"""Representation of an Jellyfin device."""
|
||||
|
||||
def __init__(self, jellyfin: JellyfinClientManager, device_id):
|
||||
"""Initialize the Jellyfin device."""
|
||||
_LOGGER.debug("New Jellyfin Device initialized with ID: %s", device_id)
|
||||
self.jellyfin = jellyfin
|
||||
self.device_id = device_id
|
||||
self.device = self.jellyfin.devices[self.device_id]
|
||||
|
||||
self._available = True
|
||||
|
||||
self.media_status_last_position = None
|
||||
self.media_status_received = None
|
||||
|
||||
async def async_added_to_hass(self):
|
||||
"""Register callback."""
|
||||
self.jellyfin.add_update_callback(self.async_update_callback, self.device_id)
|
||||
|
||||
@callback
|
||||
def async_update_callback(self, msg):
|
||||
"""Handle device updates."""
|
||||
# Check if we should update progress
|
||||
if self.device.media_position:
|
||||
if self.device.media_position != self.media_status_last_position:
|
||||
self.media_status_last_position = self.device.media_position
|
||||
self.media_status_received = dt_util.utcnow()
|
||||
elif not self.device.is_nowplaying:
|
||||
# No position, but we have an old value and are still playing
|
||||
self.media_status_last_position = None
|
||||
self.media_status_received = None
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Return True if entity is available."""
|
||||
return self._available
|
||||
|
||||
def set_available(self, value):
|
||||
"""Set available property."""
|
||||
self._available = value
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the id of this jellyfin client."""
|
||||
return self.device_id
|
||||
|
||||
@property
|
||||
def supports_remote_control(self):
|
||||
"""Return control ability."""
|
||||
return self.device.supports_remote_control
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return f"Jellyfin {self.device.name}" or DEVICE_DEFAULT_NAME
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return True if entity has to be polled for state."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
state = self.device.state
|
||||
if state == "Paused":
|
||||
return STATE_PAUSED
|
||||
if state == "Playing":
|
||||
return STATE_PLAYING
|
||||
if state == "Idle":
|
||||
return STATE_IDLE
|
||||
if state == "Off":
|
||||
return STATE_OFF
|
||||
|
||||
@property
|
||||
def app_name(self):
|
||||
"""Return current user as app_name."""
|
||||
# Ideally the media_player object would have a user property.
|
||||
return self.device.username
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
"""Content ID of current playing media."""
|
||||
return self.device.media_id
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
"""Content type of current playing media."""
|
||||
media_type = self.device.media_type
|
||||
if media_type == "Episode":
|
||||
return MEDIA_TYPE_TVSHOW
|
||||
if media_type == "Movie":
|
||||
return MEDIA_TYPE_MOVIE
|
||||
if media_type == "Trailer":
|
||||
return MEDIA_TYPE_TRAILER
|
||||
if media_type == "Music":
|
||||
return MEDIA_TYPE_MUSIC
|
||||
if media_type == "Video":
|
||||
return MEDIA_TYPE_GENERIC_VIDEO
|
||||
if media_type == "Audio":
|
||||
return MEDIA_TYPE_MUSIC
|
||||
if media_type == "TvChannel":
|
||||
return MEDIA_TYPE_CHANNEL
|
||||
return None
|
||||
|
||||
@property
|
||||
def media_duration(self):
|
||||
"""Return the duration of current playing media in seconds."""
|
||||
return self.device.media_runtime
|
||||
|
||||
@property
|
||||
def media_position(self):
|
||||
"""Return the position of current playing media in seconds."""
|
||||
return self.media_status_last_position
|
||||
|
||||
@property
|
||||
def media_position_updated_at(self):
|
||||
"""
|
||||
When was the position of the current playing media valid.
|
||||
|
||||
Returns value from homeassistant.util.dt.utcnow().
|
||||
"""
|
||||
return self.media_status_received
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
"""Return the image URL of current playing media."""
|
||||
return self.device.media_image_url
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
"""Return the title of current playing media."""
|
||||
return self.device.media_title
|
||||
|
||||
@property
|
||||
def media_season(self):
|
||||
"""Season of current playing media (TV Show only)."""
|
||||
return self.device.media_season
|
||||
|
||||
@property
|
||||
def media_series_title(self):
|
||||
"""Return the title of the series of current playing media (TV)."""
|
||||
return self.device.media_series_title
|
||||
|
||||
@property
|
||||
def media_episode(self):
|
||||
"""Return the episode of current playing media (TV only)."""
|
||||
return self.device.media_episode
|
||||
|
||||
@property
|
||||
def media_album_name(self):
|
||||
"""Return the album name of current playing media (Music only)."""
|
||||
return self.device.media_album_name
|
||||
|
||||
@property
|
||||
def media_artist(self):
|
||||
"""Return the artist of current playing media (Music track only)."""
|
||||
return self.device.media_artist
|
||||
|
||||
@property
|
||||
def media_album_artist(self):
|
||||
"""Return the album artist of current playing media (Music only)."""
|
||||
return self.device.media_album_artist
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag media player features that are supported."""
|
||||
if self.supports_remote_control:
|
||||
return SUPPORT_EMBY
|
||||
return 0
|
||||
|
||||
async def async_media_play(self):
|
||||
"""Play media."""
|
||||
await self.device.media_play()
|
||||
|
||||
async def async_media_pause(self):
|
||||
"""Pause the media player."""
|
||||
await self.device.media_pause()
|
||||
|
||||
async def async_media_stop(self):
|
||||
"""Stop the media player."""
|
||||
await self.device.media_stop()
|
||||
|
||||
async def async_media_next_track(self):
|
||||
"""Send next track command."""
|
||||
await self.device.media_next()
|
||||
|
||||
async def async_media_previous_track(self):
|
||||
"""Send next track command."""
|
||||
await self.device.media_previous()
|
||||
|
||||
async def async_media_seek(self, position):
|
||||
"""Send seek command."""
|
||||
await self.device.media_seek(position)
|
||||
49
strings.json
Normal file
49
strings.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"title": "Jellyfin",
|
||||
"config": {
|
||||
"flow_title": "Jellyfin Configuration",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Luci Config",
|
||||
"description": "Configure the connection details.",
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"conn_error": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"flow_title": "Jellyfin Configuration",
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Luci Config",
|
||||
"description": "Configure the connection details.",
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"conn_error": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
49
translations/en.json
Normal file
49
translations/en.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "Service is already configured",
|
||||
"conn_error": "Unable to connect."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Unable to connect",
|
||||
"unknown": "Unknown Error"
|
||||
},
|
||||
"flow_title": "Jellyfin Configuration",
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "Host URL",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"verify_ssl": "Verify SSL host"
|
||||
},
|
||||
"description": "Configure the connection details.",
|
||||
"title": "Jellyfin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"already_configured": "Service is already configured",
|
||||
"conn_error": "Unable to connect."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Unable to connect",
|
||||
"unknown": "Unknown Error"
|
||||
},
|
||||
"flow_title": "Jellyfin Configuration",
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "Host URL",
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"verify_ssl": "Verify SSL host"
|
||||
},
|
||||
"description": "Configure the connection details.",
|
||||
"title": "Jellyfin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Jellyfin"
|
||||
}
|
||||
Loading…
Reference in a new issue