diff --git a/__init__.py b/__init__.py index fe6d20d..a959174 100644 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,9 @@ import time import re import traceback import collections.abc -from typing import Mapping, MutableMapping, Sequence, Iterable, List +from typing import Mapping, MutableMapping, Optional, Sequence, Iterable, List, Tuple + +import voluptuous as vol from homeassistant.exceptions import ConfigEntryNotReady from jellyfin_apiclient_python import JellyfinClient @@ -13,14 +15,13 @@ 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, + ATTR_ENTITY_ID, CONF_URL, CONF_USERNAME, CONF_PASSWORD, CONF_VERIFY_SSL, CONF_CLIENT_ID, + EVENT_HOMEASSISTANT_STOP, ) import homeassistant.helpers.config_validation as cv # pylint: disable=import-error @@ -31,6 +32,7 @@ from homeassistant.helpers.dispatcher import ( # pylint: disable=import-error from .const import ( DOMAIN, SIGNAL_STATE_UPDATED, + SERVICE_SCAN, STATE_OFF, STATE_IDLE, STATE_PAUSED, @@ -40,13 +42,19 @@ from .device import JellyfinDevice _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["media_player"] +PLATFORMS = ["sensor", "media_player"] UPDATE_UNLISTENER = None USER_APP_NAME = "Home Assistant" CLIENT_VERSION = "1.0" PATH_REGEX = re.compile("^(https?://)?([^/:]+)(:[0-9]+)?(/.*)?$") +SCAN_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + } +) + def autolog(message): "Automatically log the current function details." import inspect @@ -88,21 +96,45 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): UPDATE_UNLISTENER = config_entry.add_update_listener(_update_listener) + hass.data[DOMAIN][config.get(CONF_URL)] = {} _jelly = JellyfinClientManager(hass, config) try: await _jelly.connect() - hass.data[DOMAIN][config.get(CONF_HOST)] = _jelly + hass.data[DOMAIN][config.get(CONF_URL)]["manager"] = _jelly except: _LOGGER.error("Cannot connect to Jellyfin server.") raise + async def service_trigger_scan(service): + entity_id = service.data.get(ATTR_ENTITY_ID) + + for sensor in hass.data[DOMAIN][config.get(CONF_URL)]["sensor"]["entities"]: + if sensor.entity_id == entity_id: + await sensor.async_trigger_scan() + + hass.services.async_register( + DOMAIN, + SERVICE_SCAN, + service_trigger_scan, + schema=SCAN_SERVICE_SCHEMA, + ) + for component in PLATFORMS: + hass.data[DOMAIN][config.get(CONF_URL)][component] = {} hass.async_create_task( hass.config_entries.async_forward_entry_setup(config_entry, component) ) async_dispatcher_send(hass, SIGNAL_STATE_UPDATED) + async def stop_jellyfin(event): + """Stop Jellyfin connection.""" + await _jelly.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_jellyfin) + + await _jelly.start() + return True @@ -115,9 +147,13 @@ 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.jf_client: JellyfinClient = None self.is_stopping = True self._event_loop = hass.loop + + self.host = config_entry[CONF_URL] + self._info = None + self.config_entry = config_entry self.server_url = "" @@ -227,14 +263,14 @@ class JellyfinClientManager(object): 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]) + self.jf_client = self.client_factory(self.config_entry) + self.jf_client.auth.connect_to_address(self.server_url) + result = self.jf_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) + credentials = self.jf_client.auth.credentials.get_credentials() + self.jf_client.authenticate(credentials) return True async def start(self): @@ -243,7 +279,7 @@ class JellyfinClientManager(object): def event(event_name, data): _LOGGER.debug("Event: %s", event_name) if event_name == "WebSocketConnect": - self.client.wsc.send("SessionsStart", "0,1500") + self.jf_client.wsc.send("SessionsStart", "0,1500") elif event_name == "WebSocketDisconnect": timeout_gen = self.expo(100) while not self.is_stopping: @@ -253,37 +289,36 @@ class JellyfinClientManager(object): timeout ) ) - self.client.stop() + self.jf_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) + self.update_device_list() else: - self.callback(self.client, event_name, data) + self.callback(self.jf_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.jf_client.callback = event + self.jf_client.callback_ws = event + await self.hass.async_add_executor_job(self.jf_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) + self._info = await self.hass.async_add_executor_job(self.jf_client.jellyfin._get, "System/Info") + self._sessions = self.clean_none_dict_values(await self.hass.async_add_executor_job(self.jf_client.jellyfin.get_sessions)) async def stop(self): autolog(">>>") - await self.hass.async_add_executor_job(self.client.stop) + await self.hass.async_add_executor_job(self.jf_client.stop) self.is_stopping = True - def update_device_list(self, sessions): + def update_device_list(self): """ Update device list. """ autolog(">>>") - _LOGGER.debug("sessions: %s", str(sessions)) - if sessions is None: + # _LOGGER.debug("sessions: %s", str(sessions)) + if self._sessions is None: _LOGGER.error('Error updating Jellyfin devices.') return @@ -291,8 +326,8 @@ class JellyfinClientManager(object): new_devices = [] active_devices = [] dev_update = False - for device in sessions: - _LOGGER.debug("device: %s", str(device)) + for device in self._sessions: + # _LOGGER.debug("device: %s", str(device)) dev_name = '{}.{}'.format(device['DeviceId'], device['Client']) try: @@ -385,19 +420,63 @@ class JellyfinClientManager(object): else: return False + @property + def info(self): + if self.is_stopping: + return None + + return self._info + + async def trigger_scan(self): + await self.hass.async_add_executor_job(self.jf_client.jellyfin._post, "Library/Refresh") + + async def get_item(self, id): + return await self.hass.async_add_executor_job(self.jf_client.jellyfin.get_item, id) + + async def get_items(self, query=None): + response = await self.hass.async_add_executor_job(self.jf_client.jellyfin.users, "/Items", "GET", query) + #_LOGGER.debug("get_items: %s | %s", str(query), str(response)) + return response["Items"] + 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) + await self.hass.async_add_executor_job(self.jf_client.jellyfin.post_session, session_id, "Playing/%s" % state, params) + + async def play_media(self, session_id, media_id): + params = { + "playCommand": "PlayNow", + "itemIds": media_id + } + await self.hass.async_add_executor_job(self.jf_client.jellyfin.post_session, session_id, "Playing", params) + + async def get_artwork(self, media_id) -> Tuple[Optional[str], Optional[str]]: + query = { + "format": "PNG", + "maxWidth": 500, + "maxHeight": 500 + } + image = await self.hass.async_add_executor_job(self.jf_client.jellyfin.items, "GET", "%s/Images/Primary" % media_id, query) + if image is not None: + return (image, "image/png") + + return (None, None) + + async def get_artwork_url(self, media_id) -> str: + return await self.hass.async_add_executor_job(self.jf_client.jellyfin.artwork, media_id, "Primary", 500) @property def api(self): """ Return the api. """ - return self.client.jellyfin + return self.jf_client.jellyfin @property def devices(self) -> Mapping[str, JellyfinDevice]: """ Return devices dictionary. """ return self._devices + @property + def is_available(self): + return not self.is_stopping + # Callbacks def add_new_devices_callback(self, callback): diff --git a/const.py b/const.py index 0a0692c..b8a9376 100644 --- a/const.py +++ b/const.py @@ -2,6 +2,7 @@ DOMAIN = "jellyfin" SIGNAL_STATE_UPDATED = "{}.updated".format(DOMAIN) +SERVICE_SCAN = "trigger_scan" DEFAULT_SSL = False DEFAULT_VERIFY_SSL = True diff --git a/device.py b/device.py index 7eb226d..caa4e4b 100644 --- a/device.py +++ b/device.py @@ -1,4 +1,5 @@ import logging +from typing import Optional, Tuple from .const import ( STATE_OFF, STATE_IDLE, @@ -9,10 +10,11 @@ from .const import ( _LOGGER = logging.getLogger(__name__) class JellyfinDevice(object): - """ Represents properties of an Emby Device. """ - def __init__(self, session, server): + """ Represents properties of an Jellyfin Device. """ + + def __init__(self, session, jf_manager): """Initialize Emby device object.""" - self.server = server + self.jf_manager = jf_manager self.is_active = True self.update_data(session) @@ -159,7 +161,7 @@ class JellyfinDevice(object): image_type = 'Primary' except KeyError: return None - url = self.server.api.artwork(self.media_id, image_type, 500) + url = self.jf_manager.api.artwork(self.media_id, image_type, 500) return url else: return None @@ -216,15 +218,26 @@ class JellyfinDevice(object): """ Return remote control status. """ return self.session['SupportsRemoteControl'] + async def get_item(self, id): + return await self.jf_manager.get_item(id) + + async def get_items(self, query=None): + return await self.jf_manager.get_items(query) + + async def get_artwork(self, media_id) -> Tuple[Optional[str], Optional[str]]: + return await self.jf_manager.get_artwork(media_id) + + async def get_artwork_url(self, media_id) -> str: + return await self.jf_manager.get_artwork_url(media_id) + 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) + await self.jf_manager.set_playstate(self.session_id, state, params) def media_play(self): """ Send play command to device. """ @@ -248,4 +261,7 @@ class JellyfinDevice(object): def media_seek(self, position): """ Send seek command to device. """ - return self.set_playstate('Seek', position) \ No newline at end of file + return self.set_playstate('Seek', position) + + async def play_media(self, media_id): + await self.jf_manager.play_media(self.session_id, media_id) diff --git a/media_browser.py b/media_browser.py new file mode 100644 index 0000000..965ea89 --- /dev/null +++ b/media_browser.py @@ -0,0 +1,197 @@ +import logging + +from homeassistant.components.media_player import BrowseError, BrowseMedia +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_ALBUM, + MEDIA_CLASS_ARTIST, + MEDIA_CLASS_CHANNEL, + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_EPISODE, + MEDIA_CLASS_MOVIE, + MEDIA_CLASS_MUSIC, + MEDIA_CLASS_PLAYLIST, + MEDIA_CLASS_SEASON, + MEDIA_CLASS_TRACK, + MEDIA_CLASS_TV_SHOW, + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_CHANNEL, + MEDIA_TYPE_EPISODE, + MEDIA_TYPE_MOVIE, + MEDIA_TYPE_PLAYLIST, + MEDIA_TYPE_SEASON, + MEDIA_TYPE_TRACK, + MEDIA_TYPE_TVSHOW, +) + +from .device import JellyfinDevice + +PLAYABLE_MEDIA_TYPES = [ + MEDIA_TYPE_ALBUM, + MEDIA_TYPE_ARTIST, + MEDIA_TYPE_TRACK, +] + +CONTAINER_TYPES_SPECIFIC_MEDIA_CLASS = { + MEDIA_TYPE_ALBUM: MEDIA_CLASS_ALBUM, + MEDIA_TYPE_ARTIST: MEDIA_CLASS_ARTIST, + MEDIA_TYPE_PLAYLIST: MEDIA_CLASS_PLAYLIST, + MEDIA_TYPE_SEASON: MEDIA_CLASS_SEASON, + MEDIA_TYPE_TVSHOW: MEDIA_CLASS_TV_SHOW, +} + +CHILD_TYPE_MEDIA_CLASS = { + MEDIA_TYPE_SEASON: MEDIA_CLASS_SEASON, + MEDIA_TYPE_ALBUM: MEDIA_CLASS_ALBUM, + MEDIA_TYPE_ARTIST: MEDIA_CLASS_ARTIST, + MEDIA_TYPE_MOVIE: MEDIA_CLASS_MOVIE, + MEDIA_TYPE_PLAYLIST: MEDIA_CLASS_PLAYLIST, + MEDIA_TYPE_TRACK: MEDIA_CLASS_TRACK, + MEDIA_TYPE_TVSHOW: MEDIA_CLASS_TV_SHOW, + MEDIA_TYPE_CHANNEL: MEDIA_CLASS_CHANNEL, + MEDIA_TYPE_EPISODE: MEDIA_CLASS_EPISODE, +} + +_LOGGER = logging.getLogger(__name__) + +class UnknownMediaType(BrowseError): + """Unknown media type.""" + +def Type2Mediatype(type): + switcher = { + "Movie": MEDIA_TYPE_MOVIE, + "Series": MEDIA_TYPE_TVSHOW, + "Season": MEDIA_TYPE_SEASON, + "Episode": MEDIA_TYPE_EPISODE, + "Music": MEDIA_TYPE_ALBUM, + "Audio": MEDIA_TYPE_TRACK, + "BoxSet": MEDIA_CLASS_DIRECTORY, + "Folder": MEDIA_CLASS_DIRECTORY, + "CollectionFolder": MEDIA_CLASS_DIRECTORY, + "Playlist": MEDIA_CLASS_DIRECTORY, + "MusicArtist": MEDIA_TYPE_ARTIST, + "MusicAlbum": MEDIA_TYPE_ALBUM, + "Audio": MEDIA_TYPE_TRACK, + } + return switcher[type] + +def Type2Mediaclass(type): + switcher = { + "Movie": MEDIA_CLASS_MOVIE, + "Series": MEDIA_CLASS_TV_SHOW, + "Season": MEDIA_CLASS_SEASON, + "Episode": MEDIA_CLASS_EPISODE, + "Music": MEDIA_CLASS_DIRECTORY, + "BoxSet": MEDIA_CLASS_DIRECTORY, + "Folder": MEDIA_CLASS_DIRECTORY, + "CollectionFolder": MEDIA_CLASS_DIRECTORY, + "Playlist": MEDIA_CLASS_DIRECTORY, + "MusicArtist": MEDIA_CLASS_ARTIST, + "MusicAlbum": MEDIA_CLASS_ALBUM, + "Audio": MEDIA_CLASS_TRACK, + } + return switcher[type] + +def IsPlayable(type): + switcher = { + "Movie": True, + "Series": True, + "Season": True, + "Episode": True, + "Music": False, + "BoxSet": True, + "Folder": False, + "CollectionFolder": False, + "Playlist": True, + "MusicArtist": True, + "MusicAlbum": True, + "Audio": True, + } + return switcher[type] + +async def library_items(device: JellyfinDevice, media_content_type=None, media_content_id=None): + """ + Create response payload to describe contents of a specific library. + + Used by async_browse_media. + """ + + library_info = None + query = None + + if media_content_type in [None, "library"]: + library_info = BrowseMedia( + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="library", + media_content_type="library", + title="Media Library", + can_play=False, + can_expand=True, + children=[], + ) + elif media_content_type in [MEDIA_CLASS_DIRECTORY, MEDIA_TYPE_ARTIST, MEDIA_TYPE_ALBUM, MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_SEASON]: + query = { + "ParentId": media_content_id + } + + parent_item = await device.get_item(media_content_id) + library_info = BrowseMedia( + media_class=media_content_type, + media_content_id=media_content_id, + media_content_type=media_content_type, + title=parent_item["Name"], + can_play=IsPlayable(parent_item["Type"]), + can_expand=True, + thumbnail=await device.get_artwork_url(media_content_id), + children=[], + ) + else: + query = { + "Id": media_content_id + } + library_info = BrowseMedia( + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id=media_content_id, + media_content_type=media_content_type, + title="", + can_play=True, + can_expand=False, + thumbnail=await device.get_artwork_url(media_content_id), + children=[], + ) + + items = await device.get_items(query) + for item in items: + if media_content_type in [None, "library", MEDIA_CLASS_DIRECTORY, MEDIA_TYPE_ARTIST, MEDIA_TYPE_ALBUM, MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_SEASON]: + if item["IsFolder"]: + library_info.children.append(BrowseMedia( + media_class=Type2Mediaclass(item["Type"]), + media_content_id=item["Id"], + media_content_type=Type2Mediatype(item["Type"]), + title=item["Name"], + can_play=IsPlayable(item["Type"]), + can_expand=True, + children=[], + thumbnail=await device.get_artwork_url(item["Id"]) + )) + else: + library_info.children.append(BrowseMedia( + media_class=Type2Mediaclass(item["Type"]), + media_content_id=item["Id"], + media_content_type=Type2Mediatype(item["Type"]), + title=item["Name"], + can_play=IsPlayable(item["Type"]), + can_expand=False, + children=[], + thumbnail=await device.get_artwork_url(item["Id"]) + )) + else: + library_info.title = item["Name"] + library_info.media_content_id = item["Id"], + library_info.media_content_type = Type2Mediatype(item["Type"]) + library_info.media_class = Type2Mediaclass(item["Type"]) + library_info.can_expand = False + library_info.can_play=IsPlayable(item["Type"]), + break + + return library_info diff --git a/media_player.py b/media_player.py index ed5d9c9..01c4a00 100644 --- a/media_player.py +++ b/media_player.py @@ -1,10 +1,6 @@ """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 typing import Mapping, MutableMapping, Optional, Sequence, Iterable, List, Tuple, Union from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player.const import ( @@ -12,18 +8,17 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, + SUPPORT_PLAY_MEDIA, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_STOP, + SUPPORT_BROWSE_MEDIA, ) from homeassistant.const import ( - CONF_API_KEY, - CONF_HOST, - CONF_PORT, - CONF_SSL, + CONF_URL, DEVICE_DEFAULT_NAME, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, @@ -33,15 +28,16 @@ from homeassistant.const import ( 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 . import JellyfinClientManager, autolog +from .media_browser import library_items from .const import ( DOMAIN, SIGNAL_STATE_UPDATED, ) +PLATFORM = "media_player" _LOGGER = logging.getLogger(__name__) @@ -53,8 +49,10 @@ DEFAULT_PORT = 8096 DEFAULT_SSL_PORT = 8920 DEFAULT_SSL = False -SUPPORT_EMBY = ( - SUPPORT_PAUSE +SUPPORT_JELLYFIN = ( + SUPPORT_BROWSE_MEDIA + | SUPPORT_PLAY_MEDIA + | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_STOP @@ -62,22 +60,15 @@ SUPPORT_EMBY = ( | 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.""" - + """Set up media players dynamically.""" + active_jellyfin_devices: List[JellyfinMediaPlayer] = {} inactive_jellyfin_devices: List[JellyfinMediaPlayer] = {} - _jelly: JellyfinClientManager = hass.data[DOMAIN][config_entry.data.get(CONF_HOST)] + _jelly: JellyfinClientManager = hass.data[DOMAIN][config_entry.data.get(CONF_URL)]["manager"] + hass.data[DOMAIN][_jelly.host][PLATFORM]["entities"] = [] @callback def device_update_callback(data): @@ -115,25 +106,19 @@ async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entitie _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) + _jelly.update_device_list() class JellyfinMediaPlayer(MediaPlayerEntity): """Representation of an Jellyfin device.""" - def __init__(self, jellyfin: JellyfinClientManager, device_id): + def __init__(self, jelly_cm: JellyfinClientManager, device_id): """Initialize the Jellyfin device.""" _LOGGER.debug("New Jellyfin Device initialized with ID: %s", device_id) - self.jellyfin = jellyfin + self.jelly_cm = jelly_cm self.device_id = device_id - self.device = self.jellyfin.devices[self.device_id] + self.device = self.jelly_cm.devices[self.device_id] self._available = True @@ -141,8 +126,12 @@ class JellyfinMediaPlayer(MediaPlayerEntity): 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) + self.hass.data[DOMAIN][self.jelly_cm.host][PLATFORM]["entities"].append(self) + self.jelly_cm.add_update_callback(self.async_update_callback, self.device_id) + + async def async_will_remove_from_hass(self): + self.hass.data[DOMAIN][self.jelly_cm.host][PLATFORM]["entities"].remove(self) + self.jelly_cm.remove_update_callback(self.async_update_callback, self.device_id) @callback def async_update_callback(self, msg): @@ -159,6 +148,28 @@ class JellyfinMediaPlayer(MediaPlayerEntity): self.async_write_ha_state() + async def async_get_browse_image( + self, + media_content_type: str, + media_content_id: str, + media_image_id: Optional[str] = None, + ) -> Tuple[Optional[str], Optional[str]]: + """ + fetch internally accessible image for media browser. + """ + autolog("<<<") + + if media_content_id: + return self.device.get_artwork(media_content_id) + + return (None, None) + + async def async_browse_media(self, media_content_type=None, media_content_id=None): + """Implement the media source.""" + _LOGGER.debug("-- async_browse_media: %s / %s", media_content_type, media_content_id) + return await library_items(self.device, media_content_type, media_content_id) + + @property def available(self): """Return True if entity is available.""" @@ -295,7 +306,7 @@ class JellyfinMediaPlayer(MediaPlayerEntity): def supported_features(self): """Flag media player features that are supported.""" if self.supports_remote_control: - return SUPPORT_EMBY + return SUPPORT_JELLYFIN return 0 async def async_media_play(self): @@ -320,4 +331,8 @@ class JellyfinMediaPlayer(MediaPlayerEntity): async def async_media_seek(self, position): """Send seek command.""" - await self.device.media_seek(position) \ No newline at end of file + await self.device.media_seek(position) + + async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None: + _LOGGER.debug("Play media requested: %s / %s", media_type, media_id) + await self.device.play_media(media_id) diff --git a/sensor.py b/sensor.py new file mode 100644 index 0000000..4b54268 --- /dev/null +++ b/sensor.py @@ -0,0 +1,96 @@ +"""Support to interface with the Jellyfin API.""" +import logging + +from homeassistant.helpers.entity import Entity +from homeassistant.const import ( + CONF_URL, + DEVICE_DEFAULT_NAME, + STATE_ON, + STATE_OFF, +) +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from . import JellyfinClientManager, autolog + +from .const import ( + DOMAIN, +) +PLATFORM = "sensor" + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, config_entry, async_add_entities): + + _jelly: JellyfinClientManager = hass.data[DOMAIN][config_entry.data.get(CONF_URL)]["manager"] + hass.data[DOMAIN][_jelly.host][PLATFORM]["entities"] = [] + + async_add_entities([JellyfinSensor(_jelly)], True) + + +class JellyfinSensor(Entity): + """Representation of an Jellyfin device.""" + + def __init__(self, jelly_cm: JellyfinClientManager): + """Initialize the Jellyfin device.""" + _LOGGER.debug("New Jellyfin Sensor initialized") + self.jelly_cm = jelly_cm + self._available = True + + async def async_added_to_hass(self): + self.hass.data[DOMAIN][self.jelly_cm.host][PLATFORM]["entities"].append(self) + + async def async_will_remove_from_hass(self): + self.hass.data[DOMAIN][self.jelly_cm.host][PLATFORM]["entities"].remove(self) + + @property + def available(self): + """Return True if entity is available.""" + return self.jelly_cm.is_available + + @property + def unique_id(self): + """Return the id of this jellyfin server.""" + return self.jelly_cm.info["Id"] + + @property + def device_info(self): + """Return device information about this entity.""" + return { + "identifiers": { + # Unique identifiers within a specific domain + (DOMAIN, self.jelly_cm.server_url) + }, + "manufacturer": "Jellyfin", + "model": f"Jellyfin {self.jelly_cm.info['Version']}".rstrip(), + "name": self.jelly_cm.info['ServerName'], + } + + @property + def name(self): + """Return the name of the device.""" + return f"Jellyfin {self.jelly_cm.info['ServerName']}" 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.""" + return STATE_ON if self.jelly_cm.is_available else STATE_OFF + + @property + def extra_state_attributes(self): + """Return the state attributes.""" + return { + "os": self.jelly_cm.info["OperatingSystem"], + "update_available": self.jelly_cm.info["HasUpdateAvailable"], + "version": self.jelly_cm.info["Version"], + } + + async def async_trigger_scan(self): + _LOGGER.info("Library scan triggered") + await self.jelly_cm.trigger_scan() diff --git a/services.yaml b/services.yaml new file mode 100644 index 0000000..422058f --- /dev/null +++ b/services.yaml @@ -0,0 +1,14 @@ +trigger_scan: + # Description of the service + name: Trigger library scan + description: "Trigger a scan on Jellyfin" + fields: + entity_id: + name: Entity + description: Name of entity to send command. + required: true + example: "media_player.jellyfin_firefox" + selector: + entity: + integration: jellyfin + domain: sensor