diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..fe6d20d --- /dev/null +++ b/__init__.py @@ -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) + + diff --git a/config_flow.py b/config_flow.py new file mode 100644 index 0000000..9a7b355 --- /dev/null +++ b/config_flow.py @@ -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.""" diff --git a/const.py b/const.py new file mode 100644 index 0000000..0a0692c --- /dev/null +++ b/const.py @@ -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' \ No newline at end of file diff --git a/device.py b/device.py new file mode 100644 index 0000000..7eb226d --- /dev/null +++ b/device.py @@ -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) \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..19027d5 --- /dev/null +++ b/manifest.json @@ -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" + } \ No newline at end of file diff --git a/media_player.py b/media_player.py new file mode 100644 index 0000000..ed5d9c9 --- /dev/null +++ b/media_player.py @@ -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) \ No newline at end of file diff --git a/strings.json b/strings.json new file mode 100644 index 0000000..11630a1 --- /dev/null +++ b/strings.json @@ -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%]" + } + } +} diff --git a/translations/en.json b/translations/en.json new file mode 100644 index 0000000..568cd46 --- /dev/null +++ b/translations/en.json @@ -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" +}