diff --git a/README.md b/README.md index 6b9956b..9f22638 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Jellyfin integration for Home Assistant - 1 media_player entity per device - 1 sensor per server +- Supports the "upcoming-media-card" custom card ### Media Browser diff --git a/__init__.py b/__init__.py index e220c87..7b96fcf 100644 --- a/__init__.py +++ b/__init__.py @@ -1,4 +1,5 @@ """The jellyfin component.""" +import json import logging import time import re @@ -19,6 +20,7 @@ from homeassistant.core import HomeAssistant from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( # pylint: disable=import-error ATTR_ENTITY_ID, + ATTR_ID, CONF_URL, CONF_USERNAME, CONF_PASSWORD, @@ -38,10 +40,17 @@ from .const import ( CLIENT_VERSION, SIGNAL_STATE_UPDATED, SERVICE_SCAN, + SERVICE_BROWSE, + SERVICE_DELETE, + SERVICE_YAMC_SETPAGE, + ATTR_PAGE, STATE_OFF, STATE_IDLE, STATE_PAUSED, STATE_PLAYING, + CONF_GENERATE_UPCOMING, + CONF_GENERATE_YAMC, + YAMC_PAGE_SIZE, ) _LOGGER = logging.getLogger(__name__) @@ -52,11 +61,39 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) PATH_REGEX = re.compile("^(https?://)?([^/:]+)(:[0-9]+)?(/.*)?$") -SCAN_SERVICE_SCHEMA = vol.Schema( +SERVICE_SCHEMA = vol.Schema({ +}) + +SCAN_SERVICE_SCHEMA = SERVICE_SCHEMA.extend( { vol.Required(ATTR_ENTITY_ID): cv.entity_id, } ) +YAMC_SETPAGE_SERVICE_SCHEMA = SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_PAGE): vol.All(vol.Coerce(int)) + } +) +DELETE_SERVICE_SCHEMA = SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_ID): cv.string + } +) +BROWSE_SERVICE_SCHEMA = SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_ID): cv.string + } +) + +SERVICE_TO_METHOD = { + SERVICE_SCAN: {'method': 'async_trigger_scan', 'schema': SCAN_SERVICE_SCHEMA}, + SERVICE_BROWSE: {'method': 'async_browse_item', 'schema': BROWSE_SERVICE_SCHEMA}, + SERVICE_DELETE: {'method': 'async_delete_item', 'schema': DELETE_SERVICE_SCHEMA}, + SERVICE_YAMC_SETPAGE: {'method': 'async_yamc_setpage', 'schema': YAMC_SETPAGE_SERVICE_SCHEMA}, +} def autolog(message): "Automatically log the current function details." @@ -108,19 +145,25 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): _LOGGER.error("Cannot connect to Jellyfin server.") raise - async def service_trigger_scan(service): + async def async_service_handler(service): + """Map services to methods""" + method = SERVICE_TO_METHOD.get(service.service) + params = {key: value for key, value in service.data.items() if key != "entity_id"} + 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() + await getattr(sensor, method['method'])(**params) - hass.services.async_register( - DOMAIN, - SERVICE_SCAN, - service_trigger_scan, - schema=SCAN_SERVICE_SCHEMA, - ) + for media_player in hass.data[DOMAIN][config.get(CONF_URL)]["media_player"]["entities"]: + if media_player.entity_id == entity_id: + await getattr(media_player, method['method'])(**params) + + for my_service in SERVICE_TO_METHOD: + schema = SERVICE_TO_METHOD[my_service].get('schema', SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, my_service, async_service_handler, schema=schema) for component in PLATFORMS: hass.data[DOMAIN][config.get(CONF_URL)][component] = {} @@ -143,6 +186,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry): async def _update_listener(hass, config_entry): """Update listener.""" + _LOGGER.debug("reload triggered") await hass.config_entries.async_reload(config_entry.entry_id) @@ -403,6 +447,9 @@ class JellyfinDevice(object): async def play_media(self, media_id): await self.jf_manager.play_media(self.session_id, media_id) + async def browse_item(self, media_id): + await self.jf_manager.view_media(self.session_id, media_id) + class JellyfinClientManager(object): def __init__(self, hass: HomeAssistant, config_entry): self.hass = hass @@ -414,6 +461,8 @@ class JellyfinClientManager(object): self.host = config_entry[CONF_URL] self._info = None self._data = None + self._yamc = None + self._yamc_cur_page = 1 self.config_entry = config_entry self.server_url = "" @@ -582,12 +631,27 @@ class JellyfinClientManager(object): @util.Throttle(MIN_TIME_BETWEEN_UPDATES) async def update_data(self): - self._data = await self.hass.async_add_executor_job(self.jf_client.jellyfin.shows, "/NextUp", { - 'Limit': 10, - 'UserId': "{UserId}", - "fields": "DateCreated,Studios,Genres" - }) - _LOGGER.debug("update_data: %s", str(self._data)) + if self.config_entry[CONF_GENERATE_UPCOMING]: + self._data = await self.hass.async_add_executor_job(self.jf_client.jellyfin.shows, "/NextUp", { + 'Limit': YAMC_PAGE_SIZE, + 'UserId': "{UserId}", + "fields": "DateCreated,Studios,Genres" + }) + #_LOGGER.debug("update data: %s", str(self._data)) + + if self.config_entry[CONF_GENERATE_YAMC]: + self._yamc = await self.hass.async_add_executor_job(self.jf_client.jellyfin.items, "", "GET", { + 'startIndex': (self._yamc_cur_page - 1) * YAMC_PAGE_SIZE, + 'limit': YAMC_PAGE_SIZE, + 'userId': "{UserId}", + 'includeItemTypes': "Movie", + 'sortBy': 'DateCreated', + 'sortOrder': 'Descending', + 'recursive': 'true', + "fields": "DateCreated,Studios,Genres,Taglines,ProviderIds", + 'collapseBoxSetItems': 'false', + }) + _LOGGER.debug("update yamc: %s", str(self._yamc)) def update_device_list(self): """ Update device list. """ @@ -705,7 +769,7 @@ class JellyfinClientManager(object): @property def data(self): """Upcoming card data""" - if self.is_stopping: + if self.config_entry[CONF_GENERATE_UPCOMING] == False or self.is_stopping: return None data = [] @@ -734,13 +798,105 @@ class JellyfinClientManager(object): "poster": self.get_artwork_url(item["Id"]), "fanart": self.get_artwork_url(item["Id"], "Backdrop"), "genres": ",".join(item["Genres"]), + "rating": None, + "stream_url": None, + "trakt_url": None, }) return data + @property + def yamc(self): + """Upcoming card data""" + if self.config_entry[CONF_GENERATE_YAMC] == False or self.is_stopping: + return None + + data = [] + data.append({ + 'title_default': '$title', + 'line1_default': '$tagline', + 'line2_default': '$empty', + 'line3_default': '$release - $genres', + 'line4_default': '$runtime - $rating - $info', + 'line5_default': '$date', + 'text_link_default': '$trakt_url', + 'link_default': '$stream_url', + }) + + if self._yamc is None or "Items" not in self._yamc: + return data + + for item in self._yamc["Items"]: + imdbid = None + if item["Type"] == "Movie": + if "ProviderIds" in item and "Imdb" in item["ProviderIds"]: + imdbid = item["ProviderIds"]["Imdb"] + + data.append({ + "id": item["Id"], + "type": item["Type"], + "title": item["Name"], + "tagline": item["Taglines"][0] if "Taglines" in item and len(item["Taglines"]) > 0 else "", + "flag": False, + "airdate": item["DateCreated"], + #"number": f'S{item["ParentIndexNumber"]}E{item["IndexNumber"]}', + "runtime": int(item["RunTimeTicks"] / 10000000 / 60) if "RunTimeTicks" in item else None, + "studio": ",".join(o["Name"] for o in item["Studios"]), + "release": dt.parse(item["PremiereDate"]).__format__("%Y") if "PremiereDate" in item else None, + "poster": self.get_artwork_url(item["Id"]), + "fanart": self.get_artwork_url(item["Id"], "Backdrop"), + "genres": ",".join(item["Genres"]), + "progress": 0, + "rating": None, + "stream_url": None, + 'trakt_url': f"https://trakt.tv/search/imdb/{imdbid}?id_type=movie" if imdbid else "", + }) + elif item["Type"] == "Episode": + if "ProviderIds" in item and "Imdb" in item["ProviderIds"]: + imdbid = item["ProviderIds"]["Imdb"] + + data.append({ + "id": item["Id"], + "type": item["Type"], + "title": item["SeriesName"] if "SeriesName" in item else item["Name"], + "episode": item["Name"], + "tagline": item["Taglines"][0] if "Taglines" in item else "", + "flag": False, + "airdate": item["DateCreated"], + "number": f'S{item["ParentIndexNumber"]}E{item["IndexNumber"]}', + "runtime": int(item["RunTimeTicks"] / 10000000 / 60) if "RunTimeTicks" in item else None, + "studio": ",".join(o["Name"] for o in item["Studios"]), + "release": dt.parse(item["PremiereDate"]).__format__("%d/%m/%Y") if "PremiereDate" in item else None, + "poster": self.get_artwork_url(item["Id"]), + "fanart": self.get_artwork_url(item["Id"], "Backdrop"), + "genres": ",".join(item["Genres"]), + "progress": 0, + "rating": None, + "stream_url": None, + 'trakt_url': f"https://trakt.tv/search/imdb/{imdbid}?id_type=movie" if imdbid else "", + }) + + attrs = {} + attrs["last_search"] = None + attrs["playlists"] = json.dumps([]) + attrs['total_items'] = min(50, self._yamc["TotalRecordCount"]) + attrs["page"] = self._yamc_cur_page + attrs["page_size"] = YAMC_PAGE_SIZE + attrs['data'] = json.dumps(data) + + return attrs + async def trigger_scan(self): await self.hass.async_add_executor_job(self.jf_client.jellyfin._post, "Library/Refresh") + async def delete_item(self, id): + await self.hass.async_add_executor_job(self.jf_client.jellyfin.items, f"/{id}", "DELETE") + await self.update_data(no_throttle=True) + + async def yamc_set_page(self, page): + self._yamc_cur_page = page + await self.update_data(no_throttle=True) + def get_server_url(self) -> str: return self.jf_client.config.data["auth.server"] @@ -765,6 +921,17 @@ class JellyfinClientManager(object): } await self.hass.async_add_executor_job(self.jf_client.jellyfin.post_session, session_id, "Playing", params) + async def view_media(self, session_id, media_id): + item = await self.hass.async_add_executor_job(self.jf_client.jellyfin.get_item, media_id) + _LOGGER.debug(f'view_media: {str(item)}') + + params = { + "itemId": media_id, + "itemType": item["Type"], + "itemName": item["Name"] + } + await self.hass.async_add_executor_job(self.jf_client.jellyfin.post_session, session_id, "Viewing", params) + async def get_artwork(self, media_id, type="Primary") -> Tuple[Optional[str], Optional[str]]: query = { "format": "PNG", diff --git a/config_flow.py b/config_flow.py index 9a7b355..8108bbd 100644 --- a/config_flow.py +++ b/config_flow.py @@ -19,8 +19,8 @@ from .const import ( DOMAIN, DEFAULT_SSL, DEFAULT_VERIFY_SSL, - DEFAULT_PORT, - CONN_TIMEOUT, + CONF_GENERATE_UPCOMING, + CONF_GENERATE_YAMC, ) _LOGGER = logging.getLogger(__name__) @@ -63,6 +63,8 @@ class JellyfinFlowHandler(config_entries.ConfigFlow): vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + vol.Optional(CONF_GENERATE_UPCOMING, default=False): bool, + vol.Optional(CONF_GENERATE_YAMC, default=False): bool, } if user_input is not None: @@ -70,6 +72,8 @@ class JellyfinFlowHandler(config_entries.ConfigFlow): self._username = user_input[CONF_USERNAME] self._password = user_input[CONF_PASSWORD] self._verify_ssl = user_input[CONF_VERIFY_SSL] + self._generate_upcoming = user_input[CONF_GENERATE_UPCOMING] + self._generate_yamc = user_input[CONF_GENERATE_YAMC] try: await self.async_set_unique_id(DOMAIN) @@ -83,6 +87,8 @@ class JellyfinFlowHandler(config_entries.ConfigFlow): CONF_PASSWORD: self._password, CONF_VERIFY_SSL: self._verify_ssl, CONF_CLIENT_ID: str(uuid.uuid4()), + CONF_GENERATE_UPCOMING: self._generate_upcoming, + CONF_GENERATE_YAMC: self._generate_yamc, }, ) @@ -115,6 +121,8 @@ class JellyfinOptionsFlowHandler(config_entries.OptionsFlow): 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 + self._generate_upcoming = config_entry.data[CONF_GENERATE_UPCOMING] if CONF_GENERATE_UPCOMING in config_entry.options else False + self._generate_yamc = config_entry.data[CONF_GENERATE_YAMC] if CONF_GENERATE_YAMC in config_entry.options else False async def async_step_init(self, user_input=None): """Manage the options.""" @@ -128,12 +136,16 @@ class JellyfinOptionsFlowHandler(config_entries.OptionsFlow): self._username = user_input[CONF_USERNAME] self._password = user_input[CONF_PASSWORD] self._verify_ssl = user_input[CONF_VERIFY_SSL] + self._generate_upcoming = user_input[CONF_GENERATE_UPCOMING] + self._generate_yamc = user_input[CONF_GENERATE_YAMC] 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, + vol.Optional(CONF_GENERATE_UPCOMING, default=self._generate_upcoming): bool, + vol.Optional(CONF_GENERATE_YAMC, default=self._generate_yamc): bool, } if user_input is not None: @@ -145,6 +157,8 @@ class JellyfinOptionsFlowHandler(config_entries.OptionsFlow): CONF_USERNAME: self._username, CONF_PASSWORD: self._password, CONF_VERIFY_SSL: self._verify_ssl, + CONF_GENERATE_UPCOMING: self._generate_upcoming, + CONF_GENERATE_YAMC: self._generate_yamc, }, ) diff --git a/const.py b/const.py index 4b54d3f..2aa2318 100644 --- a/const.py +++ b/const.py @@ -2,7 +2,13 @@ DOMAIN = "jellyfin" SIGNAL_STATE_UPDATED = "{}.updated".format(DOMAIN) + SERVICE_SCAN = "trigger_scan" +SERVICE_YAMC_SETPAGE = "yamc_setpage" +SERVICE_BROWSE = "browse" +SERVICE_DELETE = "delete" + +ATTR_PAGE = 'page' USER_APP_NAME = "Home Assistant" CLIENT_VERSION = "1.0" @@ -16,4 +22,9 @@ CONN_TIMEOUT = 5.0 STATE_PLAYING = 'Playing' STATE_PAUSED = 'Paused' STATE_IDLE = 'Idle' -STATE_OFF = 'Off' \ No newline at end of file +STATE_OFF = 'Off' + +CONF_GENERATE_UPCOMING = "generate_upcoming" +CONF_GENERATE_YAMC = "generate_yamc" + +YAMC_PAGE_SIZE=7 \ No newline at end of file diff --git a/media_player.py b/media_player.py index f18fbf0..91ea8c3 100644 --- a/media_player.py +++ b/media_player.py @@ -336,3 +336,7 @@ class JellyfinMediaPlayer(MediaPlayerEntity): 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) + + async def async_browse_item(self, id): + _LOGGER.debug(f"async_browse_item triggered {id}") + await self.device.browse_item(id) diff --git a/sensor.py b/sensor.py index 9aa0c84..3122a67 100644 --- a/sensor.py +++ b/sensor.py @@ -85,13 +85,30 @@ class JellyfinSensor(Entity): @property def extra_state_attributes(self): """Return the state attributes.""" - return { + extra_attr = { "os": self.jelly_cm.info["OperatingSystem"], "update_available": self.jelly_cm.info["HasUpdateAvailable"], "version": self.jelly_cm.info["Version"], - "data": self.jelly_cm.data, } + if self.jelly_cm.data: + extra_attr["data"] = self.jelly_cm.data + if self.jelly_cm.yamc: + extra_attr["yamc"] = self.jelly_cm.yamc + + return extra_attr async def async_trigger_scan(self): _LOGGER.info("Library scan triggered") await self.jelly_cm.trigger_scan() + + async def async_delete_item(self, id): + _LOGGER.debug("async_delete_item triggered") + await self.jelly_cm.delete_item(id) + self.async_schedule_update_ha_state() + + async def async_yamc_setpage(self, page): + _LOGGER.debug("YAMC setpage: %d", page) + + await self.jelly_cm.yamc_set_page(page) + self.async_schedule_update_ha_state() + diff --git a/strings.json b/strings.json index 11630a1..cb1dc18 100644 --- a/strings.json +++ b/strings.json @@ -4,13 +4,15 @@ "flow_title": "Jellyfin Configuration", "step": { "user": { - "title": "Luci Config", - "description": "Configure the connection details.", + "title": "Jellyfin Config", + "description": "Configure the integration.", "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%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "generate_upcoming": "Generate Upcoming card data", + "generate_yamc": "Generate YAMC card data" } } }, @@ -33,7 +35,9 @@ "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%]" + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "generate_upcoming": "Generate Upcoming card data", + "generate_yamc": "Generate YAMC card data" } } }, diff --git a/translations/en.json b/translations/en.json index 568cd46..01882ae 100644 --- a/translations/en.json +++ b/translations/en.json @@ -15,7 +15,9 @@ "url": "Host URL", "username": "Username", "password": "Password", - "verify_ssl": "Verify SSL host" + "verify_ssl": "Verify SSL host", + "generate_upcoming": "Generate Upcoming card data", + "generate_yamc": "Generate YAMC card data" }, "description": "Configure the connection details.", "title": "Jellyfin" @@ -38,7 +40,9 @@ "url": "Host URL", "username": "Username", "password": "Password", - "verify_ssl": "Verify SSL host" + "verify_ssl": "Verify SSL host", + "generate_upcoming": "Generate Upcoming card data", + "generate_yamc": "Generate YAMC card data" }, "description": "Configure the connection details.", "title": "Jellyfin" diff --git a/translations/fr.json b/translations/fr.json index f27eecb..c5ef384 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -15,7 +15,9 @@ "url": "URL Serveur", "username": "Utilisateur", "password": "Mot de passe", - "verify_ssl": "Vérification SSL" + "verify_ssl": "Vérification SSL", + "generate_upcoming": "Générer les données pour la carte Upcoming", + "generate_yamc": "Générer les données pour la carte YAMC" }, "description": "Configuration des paramètres de connexion.", "title": "Jellyfin" @@ -38,7 +40,9 @@ "url": "URL Serveur", "username": "Utilisateur", "password": "Mot de passe", - "verify_ssl": "Vérification SSL" + "verify_ssl": "Vérification SSL", + "generate_upcoming": "Générer les données pour la carte Upcoming", + "generate_yamc": "Générer les données pour la carte YAMC" }, "description": "Configuration des paramètres de connexion.", "title": "Jellyfin"