From 0ae5b9e880a5efacd16e66b52719dbaaf85e3cf5 Mon Sep 17 00:00:00 2001 From: j-stienstra <65826735+j-stienstra@users.noreply.github.com> Date: Fri, 12 Nov 2021 14:57:40 +0100 Subject: [PATCH] Add Jellyfin integration (#44401) * Initial commit after scaffold setup * Add initial config flow * Create initial entity * Ready for testing * Can browse, no result yet * Further improvements. Browsing is working. Now need to work on proper stream URL * Two valid URLs. Do not play in HA * First working version for music * Add thumbnail * Includes Artist->Album hierarchy * Add sorting of artists, albums and tracks * Remove code for video libraries * Improved code styling * Optimize configuration flow * Fix unit tests for config flow * Fix import order * Conform to style requirements * Use empty string as media type for non playables * 100% code coverage config_flow * Type async_get_media_source * Final docsctring fix after rebase * Add __init__ and media_source files to .coveragerc * Fix testing issues after rebase * Fix string format issues and relative const import * Remove unused manifest entries * Raise ConfigEntry exceptions, not log errors * Upgrade dependency to avoid WARNING on startup * Change to builtin tuple and list (deprecation) * Log broad exceptions * Add strict typing * Further type fixes after rebase * Retry when cannot connect, otherwise fail setup * Remove unused CONFIG_SCHEMA * Enable strict typing checks * FlowResultDict -> FlowResult * Code quality improvements * Resolve mypy.ini merge conflict * Use unique userid generated by Jellyfin * Update homeassistant/components/jellyfin/config_flow.py Remove connection class from config flow Co-authored-by: Milan Meulemans <milan.meulemans@live.be> * Minor changes for additional checks after rebase * Remove title from string and translations * Changes wrt review * Fixes based on rebase and review suggestions * Move client initialization to separate file * Remove persistent_notification, add test const.py Co-authored-by: Milan Meulemans <milan.meulemans@live.be> --- .coveragerc | 2 + .strict-typing | 1 + CODEOWNERS | 1 + homeassistant/components/jellyfin/__init__.py | 36 ++ .../components/jellyfin/client_wrapper.py | 94 +++++ .../components/jellyfin/config_flow.py | 62 ++++ homeassistant/components/jellyfin/const.py | 40 +++ .../components/jellyfin/manifest.json | 13 + .../components/jellyfin/media_source.py | 326 ++++++++++++++++++ .../components/jellyfin/strings.json | 21 ++ .../components/jellyfin/translations/en.json | 21 ++ homeassistant/generated/config_flows.py | 1 + mypy.ini | 11 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/jellyfin/__init__.py | 1 + tests/components/jellyfin/const.py | 17 + tests/components/jellyfin/test_config_flow.py | 164 +++++++++ 18 files changed, 817 insertions(+) create mode 100644 homeassistant/components/jellyfin/__init__.py create mode 100644 homeassistant/components/jellyfin/client_wrapper.py create mode 100644 homeassistant/components/jellyfin/config_flow.py create mode 100644 homeassistant/components/jellyfin/const.py create mode 100644 homeassistant/components/jellyfin/manifest.json create mode 100644 homeassistant/components/jellyfin/media_source.py create mode 100644 homeassistant/components/jellyfin/strings.json create mode 100644 homeassistant/components/jellyfin/translations/en.json create mode 100644 tests/components/jellyfin/__init__.py create mode 100644 tests/components/jellyfin/const.py create mode 100644 tests/components/jellyfin/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 53012078f33..c891a73e290 100644 --- a/.coveragerc +++ b/.coveragerc @@ -518,6 +518,8 @@ omit = homeassistant/components/isy994/switch.py homeassistant/components/itach/remote.py homeassistant/components/itunes/media_player.py + homeassistant/components/jellyfin/__init__.py + homeassistant/components/jellyfin/media_source.py homeassistant/components/joaoapps_join/* homeassistant/components/juicenet/__init__.py homeassistant/components/juicenet/const.py diff --git a/.strict-typing b/.strict-typing index 66a965b3221..01295668a64 100644 --- a/.strict-typing +++ b/.strict-typing @@ -66,6 +66,7 @@ homeassistant.components.image_processing.* homeassistant.components.input_select.* homeassistant.components.integration.* homeassistant.components.iqvia.* +homeassistant.components.jellyfin.* homeassistant.components.jewish_calendar.* homeassistant.components.knx.* homeassistant.components.kraken.* diff --git a/CODEOWNERS b/CODEOWNERS index ad69391ad29..a43b51d8b5b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -265,6 +265,7 @@ homeassistant/components/irish_rail_transport/* @ttroy50 homeassistant/components/islamic_prayer_times/* @engrbm87 homeassistant/components/isy994/* @bdraco @shbatm homeassistant/components/izone/* @Swamp-Ig +homeassistant/components/jellyfin/* @j-stienstra homeassistant/components/jewish_calendar/* @tsvi homeassistant/components/juicenet/* @jesserockz homeassistant/components/kaiterra/* @Michsior14 diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py new file mode 100644 index 00000000000..a58108b05ab --- /dev/null +++ b/homeassistant/components/jellyfin/__init__.py @@ -0,0 +1,36 @@ +"""The Jellyfin integration.""" +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input +from .const import DATA_CLIENT, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Jellyfin from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + client = create_client() + try: + await validate_input(hass, dict(entry.data), client) + except CannotConnect as ex: + raise ConfigEntryNotReady("Cannot connect to Jellyfin server") from ex + except InvalidAuth: + _LOGGER.error("Failed to login to Jellyfin server") + return False + else: + hass.data[DOMAIN][entry.entry_id] = {DATA_CLIENT: client} + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + hass.data[DOMAIN].pop(entry.entry_id) + + return True diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py new file mode 100644 index 00000000000..9f6380e2181 --- /dev/null +++ b/homeassistant/components/jellyfin/client_wrapper.py @@ -0,0 +1,94 @@ +"""Utility methods for initializing a Jellyfin client.""" +from __future__ import annotations + +import socket +from typing import Any +import uuid + +from jellyfin_apiclient_python import Jellyfin, JellyfinClient +from jellyfin_apiclient_python.api import API +from jellyfin_apiclient_python.connection_manager import ( + CONNECTION_STATE, + ConnectionManager, +) + +from homeassistant import exceptions +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import CLIENT_VERSION, USER_AGENT, USER_APP_NAME + + +async def validate_input( + hass: HomeAssistant, user_input: dict[str, Any], client: JellyfinClient +) -> str: + """Validate that the provided url and credentials can be used to connect.""" + url = user_input[CONF_URL] + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + userid = await hass.async_add_executor_job( + _connect, client, url, username, password + ) + + return userid + + +def create_client() -> JellyfinClient: + """Create a new Jellyfin client.""" + jellyfin = Jellyfin() + client = jellyfin.get_client() + _setup_client(client) + return client + + +def _setup_client(client: JellyfinClient) -> None: + """Configure the Jellyfin client with a number of required properties.""" + player_name = socket.gethostname() + client_uuid = str(uuid.uuid4()) + + client.config.app(USER_APP_NAME, CLIENT_VERSION, player_name, client_uuid) + client.config.http(USER_AGENT) + + +def _connect(client: JellyfinClient, url: str, username: str, password: str) -> str: + """Connect to the Jellyfin server and assert that the user can login.""" + client.config.data["auth.ssl"] = url.startswith("https") + + _connect_to_address(client.auth, url) + _login(client.auth, url, username, password) + return _get_id(client.jellyfin) + + +def _connect_to_address(connection_manager: ConnectionManager, url: str) -> None: + """Connect to the Jellyfin server.""" + state = connection_manager.connect_to_address(url) + if state["State"] != CONNECTION_STATE["ServerSignIn"]: + raise CannotConnect + + +def _login( + connection_manager: ConnectionManager, + url: str, + username: str, + password: str, +) -> None: + """Assert that the user can log in to the Jellyfin server.""" + response = connection_manager.login(url, username, password) + if "AccessToken" not in response: + raise InvalidAuth + + +def _get_id(api: API) -> str: + """Set the unique userid from a Jellyfin server.""" + settings: dict[str, Any] = api.get_user_settings() + userid: str = settings["Id"] + return userid + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate the server is unreachable.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate the credentials are invalid.""" diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py new file mode 100644 index 00000000000..55de7c12e44 --- /dev/null +++ b/homeassistant/components/jellyfin/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for the Jellyfin integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Jellyfin.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a user defined configuration.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + errors: dict[str, str] = {} + + if user_input is not None: + client = create_client() + try: + userid = await validate_input(self.hass, user_input, client) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception as ex: # pylint: disable=broad-except + errors["base"] = "unknown" + _LOGGER.exception(ex) + else: + await self.async_set_unique_id(userid) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=user_input[CONF_URL], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/jellyfin/const.py b/homeassistant/components/jellyfin/const.py new file mode 100644 index 00000000000..d8379859e54 --- /dev/null +++ b/homeassistant/components/jellyfin/const.py @@ -0,0 +1,40 @@ +"""Constants for the Jellyfin integration.""" + +from typing import Final + +DOMAIN: Final = "jellyfin" + +CLIENT_VERSION: Final = "1.0" + +COLLECTION_TYPE_MOVIES: Final = "movies" +COLLECTION_TYPE_TVSHOWS: Final = "tvshows" +COLLECTION_TYPE_MUSIC: Final = "music" + +DATA_CLIENT: Final = "client" + +ITEM_KEY_COLLECTION_TYPE: Final = "CollectionType" +ITEM_KEY_ID: Final = "Id" +ITEM_KEY_IMAGE_TAGS: Final = "ImageTags" +ITEM_KEY_INDEX_NUMBER: Final = "IndexNumber" +ITEM_KEY_MEDIA_SOURCES: Final = "MediaSources" +ITEM_KEY_MEDIA_TYPE: Final = "MediaType" +ITEM_KEY_NAME: Final = "Name" + +ITEM_TYPE_ALBUM: Final = "MusicAlbum" +ITEM_TYPE_ARTIST: Final = "MusicArtist" +ITEM_TYPE_AUDIO: Final = "Audio" +ITEM_TYPE_LIBRARY: Final = "CollectionFolder" + +MAX_IMAGE_WIDTH: Final = 500 +MAX_STREAMING_BITRATE: Final = "140000000" + + +MEDIA_SOURCE_KEY_PATH: Final = "Path" + +MEDIA_TYPE_AUDIO: Final = "Audio" +MEDIA_TYPE_NONE: Final = "" + +SUPPORTED_COLLECTION_TYPES: Final = [COLLECTION_TYPE_MUSIC] + +USER_APP_NAME: Final = "Home Assistant" +USER_AGENT: Final = f"Home-Assistant/{CLIENT_VERSION}" diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json new file mode 100644 index 00000000000..345cecc2eb6 --- /dev/null +++ b/homeassistant/components/jellyfin/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "jellyfin", + "name": "Jellyfin", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/jellyfin", + "requirements": [ + "jellyfin-apiclient-python==1.7.2" + ], + "iot_class": "local_polling", + "codeowners": [ + "@j-stienstra" + ] +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py new file mode 100644 index 00000000000..55e849a1f14 --- /dev/null +++ b/homeassistant/components/jellyfin/media_source.py @@ -0,0 +1,326 @@ +"""The Media Source implementation for the Jellyfin integration.""" +from __future__ import annotations + +import logging +import mimetypes +from typing import Any +import urllib.parse + +from jellyfin_apiclient_python.api import jellyfin_url +from jellyfin_apiclient_python.client import JellyfinClient + +from homeassistant.components.media_player.const import ( + MEDIA_CLASS_ALBUM, + MEDIA_CLASS_ARTIST, + MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_TRACK, +) +from homeassistant.components.media_player.errors import BrowseError +from homeassistant.components.media_source.models import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, +) +from homeassistant.core import HomeAssistant + +from .const import ( + COLLECTION_TYPE_MUSIC, + DATA_CLIENT, + DOMAIN, + ITEM_KEY_COLLECTION_TYPE, + ITEM_KEY_ID, + ITEM_KEY_IMAGE_TAGS, + ITEM_KEY_INDEX_NUMBER, + ITEM_KEY_MEDIA_SOURCES, + ITEM_KEY_MEDIA_TYPE, + ITEM_KEY_NAME, + ITEM_TYPE_ALBUM, + ITEM_TYPE_ARTIST, + ITEM_TYPE_AUDIO, + ITEM_TYPE_LIBRARY, + MAX_IMAGE_WIDTH, + MAX_STREAMING_BITRATE, + MEDIA_SOURCE_KEY_PATH, + MEDIA_TYPE_AUDIO, + MEDIA_TYPE_NONE, + SUPPORTED_COLLECTION_TYPES, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_media_source(hass: HomeAssistant) -> MediaSource: + """Set up Jellyfin media source.""" + # Currently only a single Jellyfin server is supported + entry = hass.config_entries.async_entries(DOMAIN)[0] + + data = hass.data[DOMAIN][entry.entry_id] + client: JellyfinClient = data[DATA_CLIENT] + + return JellyfinSource(hass, client) + + +class JellyfinSource(MediaSource): + """Represents a Jellyfin server.""" + + name: str = "Jellyfin" + + def __init__(self, hass: HomeAssistant, client: JellyfinClient) -> None: + """Initialize the Jellyfin media source.""" + super().__init__(DOMAIN) + + self.hass = hass + + self.client = client + self.api = client.jellyfin + self.url = jellyfin_url(client, "") + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Return a streamable URL and associated mime type.""" + media_item = await self.hass.async_add_executor_job( + self.api.get_item, item.identifier + ) + + stream_url = self._get_stream_url(media_item) + mime_type = _media_mime_type(media_item) + + return PlayMedia(stream_url, mime_type) + + async def async_browse_media(self, item: MediaSourceItem) -> BrowseMediaSource: + """Return a browsable Jellyfin media source.""" + if not item.identifier: + return await self._build_libraries() + + media_item = await self.hass.async_add_executor_job( + self.api.get_item, item.identifier + ) + + item_type = media_item["Type"] + if item_type == ITEM_TYPE_LIBRARY: + return await self._build_library(media_item, True) + if item_type == ITEM_TYPE_ARTIST: + return await self._build_artist(media_item, True) + if item_type == ITEM_TYPE_ALBUM: + return await self._build_album(media_item, True) + + raise BrowseError(f"Unsupported item type {item_type}") + + async def _build_libraries(self) -> BrowseMediaSource: + """Return all supported libraries the user has access to as media sources.""" + base = BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_NONE, + title=self.name, + can_play=False, + can_expand=True, + children_media_class=MEDIA_CLASS_DIRECTORY, + ) + + libraries = await self._get_libraries() + + base.children = [] + + for library in libraries: + base.children.append(await self._build_library(library, False)) + + return base + + async def _get_libraries(self) -> list[dict[str, Any]]: + """Return all supported libraries a user has access to.""" + response = await self.hass.async_add_executor_job(self.api.get_media_folders) + libraries = response["Items"] + result = [] + for library in libraries: + if ITEM_KEY_COLLECTION_TYPE in library: + if library[ITEM_KEY_COLLECTION_TYPE] in SUPPORTED_COLLECTION_TYPES: + result.append(library) + return result + + async def _build_library( + self, library: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single library as a browsable media source.""" + collection_type = library[ITEM_KEY_COLLECTION_TYPE] + + if collection_type == COLLECTION_TYPE_MUSIC: + return await self._build_music_library(library, include_children) + + raise BrowseError(f"Unsupported collection type {collection_type}") + + async def _build_music_library( + self, library: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single music library as a browsable media source.""" + library_id = library[ITEM_KEY_ID] + library_name = library[ITEM_KEY_NAME] + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=library_id, + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_NONE, + title=library_name, + can_play=False, + can_expand=True, + ) + + if include_children: + result.children_media_class = MEDIA_CLASS_ARTIST + result.children = await self._build_artists(library_id) # type: ignore[assignment] + + return result + + async def _build_artists(self, library_id: str) -> list[BrowseMediaSource]: + """Return all artists in the music library.""" + artists = await self._get_children(library_id, ITEM_TYPE_ARTIST) + artists = sorted(artists, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + return [await self._build_artist(artist, False) for artist in artists] + + async def _build_artist( + self, artist: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single artist as a browsable media source.""" + artist_id = artist[ITEM_KEY_ID] + artist_name = artist[ITEM_KEY_NAME] + thumbnail_url = self._get_thumbnail_url(artist) + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=artist_id, + media_class=MEDIA_CLASS_ARTIST, + media_content_type=MEDIA_TYPE_NONE, + title=artist_name, + can_play=False, + can_expand=True, + thumbnail=thumbnail_url, + ) + + if include_children: + result.children_media_class = MEDIA_CLASS_ALBUM + result.children = await self._build_albums(artist_id) # type: ignore[assignment] + + return result + + async def _build_albums(self, artist_id: str) -> list[BrowseMediaSource]: + """Return all albums of a single artist as browsable media sources.""" + albums = await self._get_children(artist_id, ITEM_TYPE_ALBUM) + albums = sorted(albums, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + return [await self._build_album(album, False) for album in albums] + + async def _build_album( + self, album: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single album as a browsable media source.""" + album_id = album[ITEM_KEY_ID] + album_title = album[ITEM_KEY_NAME] + thumbnail_url = self._get_thumbnail_url(album) + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=album_id, + media_class=MEDIA_CLASS_ALBUM, + media_content_type=MEDIA_TYPE_NONE, + title=album_title, + can_play=False, + can_expand=True, + thumbnail=thumbnail_url, + ) + + if include_children: + result.children_media_class = MEDIA_CLASS_TRACK + result.children = await self._build_tracks(album_id) # type: ignore[assignment] + + return result + + async def _build_tracks(self, album_id: str) -> list[BrowseMediaSource]: + """Return all tracks of a single album as browsable media sources.""" + tracks = await self._get_children(album_id, ITEM_TYPE_AUDIO) + tracks = sorted(tracks, key=lambda k: k[ITEM_KEY_INDEX_NUMBER]) # type: ignore[no-any-return] + return [self._build_track(track) for track in tracks] + + def _build_track(self, track: dict[str, Any]) -> BrowseMediaSource: + """Return a single track as a browsable media source.""" + track_id = track[ITEM_KEY_ID] + track_title = track[ITEM_KEY_NAME] + mime_type = _media_mime_type(track) + thumbnail_url = self._get_thumbnail_url(track) + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=track_id, + media_class=MEDIA_CLASS_TRACK, + media_content_type=mime_type, + title=track_title, + can_play=True, + can_expand=False, + thumbnail=thumbnail_url, + ) + + return result + + async def _get_children( + self, parent_id: str, item_type: str + ) -> list[dict[str, Any]]: + """Return all children for the parent_id whose item type is item_type.""" + params = { + "Recursive": "true", + "ParentId": parent_id, + "IncludeItemTypes": item_type, + } + if item_type == ITEM_TYPE_AUDIO: + params["Fields"] = ITEM_KEY_MEDIA_SOURCES + + result = await self.hass.async_add_executor_job(self.api.user_items, "", params) + return result["Items"] # type: ignore[no-any-return] + + def _get_thumbnail_url(self, media_item: dict[str, Any]) -> str | None: + """Return the URL for the primary image of a media item if available.""" + image_tags = media_item[ITEM_KEY_IMAGE_TAGS] + + if "Primary" not in image_tags: + return None + + item_id = media_item[ITEM_KEY_ID] + return str(self.api.artwork(item_id, "Primary", MAX_IMAGE_WIDTH)) + + def _get_stream_url(self, media_item: dict[str, Any]) -> str: + """Return the stream URL for a media item.""" + media_type = media_item[ITEM_KEY_MEDIA_TYPE] + + if media_type == MEDIA_TYPE_AUDIO: + return self._get_audio_stream_url(media_item) + + raise BrowseError(f"Unsupported media type {media_type}") + + def _get_audio_stream_url(self, media_item: dict[str, Any]) -> str: + """Return the stream URL for a music media item.""" + item_id = media_item[ITEM_KEY_ID] + user_id = self.client.config.data["auth.user_id"] + device_id = self.client.config.data["app.device_id"] + api_key = self.client.config.data["auth.token"] + + params = urllib.parse.urlencode( + { + "UserId": user_id, + "DeviceId": device_id, + "api_key": api_key, + "MaxStreamingBitrate": MAX_STREAMING_BITRATE, + } + ) + + return f"{self.url}Audio/{item_id}/universal?{params}" + + +def _media_mime_type(media_item: dict[str, Any]) -> str: + """Return the mime type of a media item.""" + media_source = media_item[ITEM_KEY_MEDIA_SOURCES][0] + path = media_source[MEDIA_SOURCE_KEY_PATH] + mime_type, _ = mimetypes.guess_type(path) + + if mime_type is not None: + return mime_type + + raise BrowseError(f"Unable to determine mime type for path {path}") diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json new file mode 100644 index 00000000000..7587c1d86d9 --- /dev/null +++ b/homeassistant/components/jellyfin/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/en.json b/homeassistant/components/jellyfin/translations/en.json new file mode 100644 index 00000000000..05cdf6e9ea9 --- /dev/null +++ b/homeassistant/components/jellyfin/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Only a single Jellyfin server is currently supported" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "url": "URL", + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4af6b656745..b620366a31b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -147,6 +147,7 @@ FLOWS = [ "islamic_prayer_times", "isy994", "izone", + "jellyfin", "juicenet", "keenetic_ndms2", "kmtronic", diff --git a/mypy.ini b/mypy.ini index 91bca58ba6b..f879ed5799b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -737,6 +737,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.jellyfin.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.jewish_calendar.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index c800a9a9902..fd482b7ddda 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -899,6 +899,9 @@ iperf3==0.1.11 # homeassistant.components.gogogate2 ismartgate==4.0.4 +# homeassistant.components.jellyfin +jellyfin-apiclient-python==1.7.2 + # homeassistant.components.rest jsonpath==0.82 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86940d279dd..9421c4f3213 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -557,6 +557,9 @@ iotawattpy==0.1.0 # homeassistant.components.gogogate2 ismartgate==4.0.4 +# homeassistant.components.jellyfin +jellyfin-apiclient-python==1.7.2 + # homeassistant.components.rest jsonpath==0.82 diff --git a/tests/components/jellyfin/__init__.py b/tests/components/jellyfin/__init__.py new file mode 100644 index 00000000000..e5ff9ab3207 --- /dev/null +++ b/tests/components/jellyfin/__init__.py @@ -0,0 +1 @@ +"""Tests for the jellyfin integration.""" diff --git a/tests/components/jellyfin/const.py b/tests/components/jellyfin/const.py new file mode 100644 index 00000000000..b33f00818b7 --- /dev/null +++ b/tests/components/jellyfin/const.py @@ -0,0 +1,17 @@ +"""Constants for the Jellyfin integration tests.""" + +from typing import Final + +from jellyfin_apiclient_python.connection_manager import CONNECTION_STATE + +TEST_URL: Final = "https://example.com" +TEST_USERNAME: Final = "test-username" +TEST_PASSWORD: Final = "test-password" + +MOCK_SUCCESFUL_CONNECTION_STATE: Final = {"State": CONNECTION_STATE["ServerSignIn"]} +MOCK_SUCCESFUL_LOGIN_RESPONSE: Final = {"AccessToken": "Test"} + +MOCK_UNSUCCESFUL_CONNECTION_STATE: Final = {"State": CONNECTION_STATE["Unavailable"]} +MOCK_UNSUCCESFUL_LOGIN_RESPONSE: Final = {""} + +MOCK_USER_SETTINGS: Final = {"Id": "123"} diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py new file mode 100644 index 00000000000..cc23265e011 --- /dev/null +++ b/tests/components/jellyfin/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test the jellyfin config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.jellyfin.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import ( + MOCK_SUCCESFUL_CONNECTION_STATE, + MOCK_SUCCESFUL_LOGIN_RESPONSE, + MOCK_UNSUCCESFUL_CONNECTION_STATE, + MOCK_UNSUCCESFUL_LOGIN_RESPONSE, + MOCK_USER_SETTINGS, + TEST_PASSWORD, + TEST_URL, + TEST_USERNAME, +) + +from tests.common import MockConfigEntry + + +async def test_abort_if_existing_entry(hass: HomeAssistant): + """Check flow abort when an entry already exist.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_form(hass: HomeAssistant): + """Test the complete configuration form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address", + return_value=MOCK_SUCCESFUL_CONNECTION_STATE, + ) as mock_connect, patch( + "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.login", + return_value=MOCK_SUCCESFUL_LOGIN_RESPONSE, + ) as mock_login, patch( + "homeassistant.components.jellyfin.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.jellyfin.client_wrapper.API.get_user_settings", + return_value=MOCK_USER_SETTINGS, + ) as mock_set_id: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == TEST_URL + assert result2["data"] == { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + } + + assert len(mock_connect.mock_calls) == 1 + assert len(mock_login.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_set_id.mock_calls) == 1 + + +async def test_form_cannot_connect(hass: HomeAssistant): + """Test we handle an unreachable server.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address", + return_value=MOCK_UNSUCCESFUL_CONNECTION_STATE, + ) as mock_connect: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + assert len(mock_connect.mock_calls) == 1 + + +async def test_form_invalid_auth(hass: HomeAssistant): + """Test that we can handle invalid credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address", + return_value=MOCK_SUCCESFUL_CONNECTION_STATE, + ) as mock_connect, patch( + "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.login", + return_value=MOCK_UNSUCCESFUL_LOGIN_RESPONSE, + ) as mock_login: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + assert len(mock_connect.mock_calls) == 1 + assert len(mock_login.mock_calls) == 1 + + +async def test_form_exception(hass: HomeAssistant): + """Test we handle an unexpected exception during server setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.jellyfin.client_wrapper.ConnectionManager.connect_to_address", + side_effect=Exception("UnknownException"), + ) as mock_connect: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + assert len(mock_connect.mock_calls) == 1 -- GitLab