Initial baseline

This commit is contained in:
Chris Browet 2021-05-04 11:46:09 +02:00
parent fce57a871a
commit e73f03f697
8 changed files with 1306 additions and 0 deletions

445
__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"
}