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>
This commit is contained in:
j-stienstra 2021-11-12 14:57:40 +01:00 committed by GitHub
parent 733193b5ad
commit 0ae5b9e880
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 817 additions and 0 deletions

View File

@ -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

View File

@ -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.*

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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
)

View File

@ -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}"

View File

@ -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"
]
}

View File

@ -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}")

View File

@ -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%]"
}
}
}

View File

@ -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"
}
}
}
}
}

View File

@ -147,6 +147,7 @@ FLOWS = [
"islamic_prayer_times",
"isy994",
"izone",
"jellyfin",
"juicenet",
"keenetic_ndms2",
"kmtronic",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
"""Tests for the jellyfin integration."""

View File

@ -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"}

View File

@ -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