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:
parent
733193b5ad
commit
0ae5b9e880
18 changed files with 817 additions and 0 deletions
|
@ -518,6 +518,8 @@ omit =
|
||||||
homeassistant/components/isy994/switch.py
|
homeassistant/components/isy994/switch.py
|
||||||
homeassistant/components/itach/remote.py
|
homeassistant/components/itach/remote.py
|
||||||
homeassistant/components/itunes/media_player.py
|
homeassistant/components/itunes/media_player.py
|
||||||
|
homeassistant/components/jellyfin/__init__.py
|
||||||
|
homeassistant/components/jellyfin/media_source.py
|
||||||
homeassistant/components/joaoapps_join/*
|
homeassistant/components/joaoapps_join/*
|
||||||
homeassistant/components/juicenet/__init__.py
|
homeassistant/components/juicenet/__init__.py
|
||||||
homeassistant/components/juicenet/const.py
|
homeassistant/components/juicenet/const.py
|
||||||
|
|
|
@ -66,6 +66,7 @@ homeassistant.components.image_processing.*
|
||||||
homeassistant.components.input_select.*
|
homeassistant.components.input_select.*
|
||||||
homeassistant.components.integration.*
|
homeassistant.components.integration.*
|
||||||
homeassistant.components.iqvia.*
|
homeassistant.components.iqvia.*
|
||||||
|
homeassistant.components.jellyfin.*
|
||||||
homeassistant.components.jewish_calendar.*
|
homeassistant.components.jewish_calendar.*
|
||||||
homeassistant.components.knx.*
|
homeassistant.components.knx.*
|
||||||
homeassistant.components.kraken.*
|
homeassistant.components.kraken.*
|
||||||
|
|
|
@ -265,6 +265,7 @@ homeassistant/components/irish_rail_transport/* @ttroy50
|
||||||
homeassistant/components/islamic_prayer_times/* @engrbm87
|
homeassistant/components/islamic_prayer_times/* @engrbm87
|
||||||
homeassistant/components/isy994/* @bdraco @shbatm
|
homeassistant/components/isy994/* @bdraco @shbatm
|
||||||
homeassistant/components/izone/* @Swamp-Ig
|
homeassistant/components/izone/* @Swamp-Ig
|
||||||
|
homeassistant/components/jellyfin/* @j-stienstra
|
||||||
homeassistant/components/jewish_calendar/* @tsvi
|
homeassistant/components/jewish_calendar/* @tsvi
|
||||||
homeassistant/components/juicenet/* @jesserockz
|
homeassistant/components/juicenet/* @jesserockz
|
||||||
homeassistant/components/kaiterra/* @Michsior14
|
homeassistant/components/kaiterra/* @Michsior14
|
||||||
|
|
36
homeassistant/components/jellyfin/__init__.py
Normal file
36
homeassistant/components/jellyfin/__init__.py
Normal 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
|
94
homeassistant/components/jellyfin/client_wrapper.py
Normal file
94
homeassistant/components/jellyfin/client_wrapper.py
Normal 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."""
|
62
homeassistant/components/jellyfin/config_flow.py
Normal file
62
homeassistant/components/jellyfin/config_flow.py
Normal 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
|
||||||
|
)
|
40
homeassistant/components/jellyfin/const.py
Normal file
40
homeassistant/components/jellyfin/const.py
Normal 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}"
|
13
homeassistant/components/jellyfin/manifest.json
Normal file
13
homeassistant/components/jellyfin/manifest.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
326
homeassistant/components/jellyfin/media_source.py
Normal file
326
homeassistant/components/jellyfin/media_source.py
Normal 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}")
|
21
homeassistant/components/jellyfin/strings.json
Normal file
21
homeassistant/components/jellyfin/strings.json
Normal 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%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
homeassistant/components/jellyfin/translations/en.json
Normal file
21
homeassistant/components/jellyfin/translations/en.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -147,6 +147,7 @@ FLOWS = [
|
||||||
"islamic_prayer_times",
|
"islamic_prayer_times",
|
||||||
"isy994",
|
"isy994",
|
||||||
"izone",
|
"izone",
|
||||||
|
"jellyfin",
|
||||||
"juicenet",
|
"juicenet",
|
||||||
"keenetic_ndms2",
|
"keenetic_ndms2",
|
||||||
"kmtronic",
|
"kmtronic",
|
||||||
|
|
11
mypy.ini
11
mypy.ini
|
@ -737,6 +737,17 @@ no_implicit_optional = true
|
||||||
warn_return_any = true
|
warn_return_any = true
|
||||||
warn_unreachable = 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.*]
|
[mypy-homeassistant.components.jewish_calendar.*]
|
||||||
check_untyped_defs = true
|
check_untyped_defs = true
|
||||||
disallow_incomplete_defs = true
|
disallow_incomplete_defs = true
|
||||||
|
|
|
@ -899,6 +899,9 @@ iperf3==0.1.11
|
||||||
# homeassistant.components.gogogate2
|
# homeassistant.components.gogogate2
|
||||||
ismartgate==4.0.4
|
ismartgate==4.0.4
|
||||||
|
|
||||||
|
# homeassistant.components.jellyfin
|
||||||
|
jellyfin-apiclient-python==1.7.2
|
||||||
|
|
||||||
# homeassistant.components.rest
|
# homeassistant.components.rest
|
||||||
jsonpath==0.82
|
jsonpath==0.82
|
||||||
|
|
||||||
|
|
|
@ -557,6 +557,9 @@ iotawattpy==0.1.0
|
||||||
# homeassistant.components.gogogate2
|
# homeassistant.components.gogogate2
|
||||||
ismartgate==4.0.4
|
ismartgate==4.0.4
|
||||||
|
|
||||||
|
# homeassistant.components.jellyfin
|
||||||
|
jellyfin-apiclient-python==1.7.2
|
||||||
|
|
||||||
# homeassistant.components.rest
|
# homeassistant.components.rest
|
||||||
jsonpath==0.82
|
jsonpath==0.82
|
||||||
|
|
||||||
|
|
1
tests/components/jellyfin/__init__.py
Normal file
1
tests/components/jellyfin/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for the jellyfin integration."""
|
17
tests/components/jellyfin/const.py
Normal file
17
tests/components/jellyfin/const.py
Normal 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"}
|
164
tests/components/jellyfin/test_config_flow.py
Normal file
164
tests/components/jellyfin/test_config_flow.py
Normal 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
|
Loading…
Add table
Add a link
Reference in a new issue