From 269e533991005b4b5d4343d7c284b3d22c2a76e6 Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:29:01 +0200 Subject: [PATCH] Add reconfigure flow to Jellyfin (#181521) Co-authored-by: Josef Zweck --- .../components/jellyfin/config_flow.py | 47 ++++++ .../components/jellyfin/strings.json | 10 +- tests/components/jellyfin/const.py | 7 + tests/components/jellyfin/test_config_flow.py | 137 +++++++++++++++++- 4 files changed, 197 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index 660ea0082bd2..c4f8dd63c9ec 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -137,6 +137,53 @@ class JellyfinConfigFlow(ConfigFlow, domain=DOMAIN): step_id="reauth_confirm", data_schema=REAUTH_DATA_SCHEMA, errors=errors ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the integration.""" + errors: dict[str, str] = {} + reconfigure_entry = self._get_reconfigure_entry() + + if user_input is not None: + user_input = { + **user_input, + CONF_URL: user_input[CONF_URL].rstrip("/"), + } + device_id: str = reconfigure_entry.data.get( + CONF_CLIENT_DEVICE_ID, reconfigure_entry.entry_id + ) + new_input = { + **reconfigure_entry.data, + **user_input, + CONF_CLIENT_DEVICE_ID: device_id, + } + client = create_client(device_id=device_id) + + try: + user_id, _ = await validate_input(self.hass, new_input, client) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: + errors["base"] = "unknown" + _LOGGER.exception("Unexpected exception") + else: + # Jellyfin usernames can change, but the user ID must remain stable. + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + reconfigure_entry, data=new_input + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=self.add_suggested_values_to_schema( + STEP_USER_DATA_SCHEMA, reconfigure_entry.data | (user_input or {}) + ), + errors=errors, + ) + @staticmethod @callback @override diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json index c671e60d5da7..c685ffe14234 100644 --- a/homeassistant/components/jellyfin/strings.json +++ b/homeassistant/components/jellyfin/strings.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "unique_id_mismatch": "The Jellyfin credentials do not match the configured account" }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -16,6 +17,13 @@ "description": "The Jellyfin integration needs to re-authenticate your account", "title": "[%key:common::config_flow::title::reauth%]" }, + "reconfigure": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "url": "[%key:common::config_flow::data::url%]", + "username": "[%key:common::config_flow::data::username%]" + } + }, "user": { "data": { "password": "[%key:common::config_flow::data::password%]", diff --git a/tests/components/jellyfin/const.py b/tests/components/jellyfin/const.py index 157c25b4af45..6723d43674e2 100644 --- a/tests/components/jellyfin/const.py +++ b/tests/components/jellyfin/const.py @@ -5,6 +5,7 @@ from typing import Final from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME TEST_URL: Final = "https://example.com" +TEST_NEW_URL: Final = "https://new.example.com" TEST_USERNAME: Final = "test-username" TEST_PASSWORD: Final = "test-password" @@ -17,3 +18,9 @@ USER_INPUT: Final = { REAUTH_INPUT: Final = { CONF_PASSWORD: TEST_PASSWORD, } + +RECONFIGURE_INPUT: Final = { + CONF_URL: f"{TEST_NEW_URL}/", + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, +} diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index e12e21679af6..cb61c674051b 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -1,11 +1,12 @@ """Test the jellyfin config flow.""" -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest from voluptuous.error import Invalid from homeassistant import config_entries +from homeassistant.components.jellyfin.client_wrapper import CannotConnect, InvalidAuth from homeassistant.components.jellyfin.const import ( CONF_AUDIO_CODEC, CONF_CLIENT_DEVICE_ID, @@ -16,9 +17,17 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from . import async_load_json_fixture -from .const import REAUTH_INPUT, TEST_PASSWORD, TEST_URL, TEST_USERNAME, USER_INPUT +from .const import ( + REAUTH_INPUT, + RECONFIGURE_INPUT, + TEST_NEW_URL, + TEST_PASSWORD, + TEST_URL, + TEST_USERNAME, + USER_INPUT, +) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, get_schema_suggested_value pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -458,6 +467,128 @@ async def test_reauth_exception( assert result3["reason"] == "reauth_successful" +@pytest.mark.usefixtures("mock_jellyfin") +async def test_reconfigure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, + mock_setup_entry: MagicMock, +) -> None: + """Test reconfiguring a Jellyfin server.""" + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, + data={CONF_CLIENT_DEVICE_ID: "TEST-UUID", **mock_config_entry.data}, + ) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {} + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_URL) == TEST_URL + ) + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_USERNAME) + == TEST_USERNAME + ) + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_PASSWORD) + == TEST_PASSWORD + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=RECONFIGURE_INPUT + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data == { + CONF_CLIENT_DEVICE_ID: "TEST-UUID", + CONF_URL: TEST_NEW_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + } + mock_client.auth.connect_to_address.assert_called_once_with(TEST_NEW_URL) + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_jellyfin") +async def test_reconfigure_unique_id_mismatch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test reconfiguring with a different Jellyfin account.""" + mock_client.jellyfin.get_user_settings.return_value = {"Id": "OTHER-USER-UUID"} + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=RECONFIGURE_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data[CONF_URL] == TEST_URL + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (CannotConnect(), "cannot_connect"), + (InvalidAuth(), "invalid_auth"), + (Exception("Unexpected error"), "unknown"), + ], +) +@pytest.mark.usefixtures("mock_jellyfin") +async def test_reconfigure_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_error: str, +) -> None: + """Test an error while reconfiguring a Jellyfin server.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + with patch( + "homeassistant.components.jellyfin.config_flow.validate_input", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=RECONFIGURE_INPUT + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": expected_error} + assert mock_config_entry.data[CONF_URL] == TEST_URL + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_URL) + == TEST_NEW_URL + ) + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_USERNAME) + == TEST_USERNAME + ) + assert ( + get_schema_suggested_value(result["data_schema"].schema, CONF_PASSWORD) + == TEST_PASSWORD + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=RECONFIGURE_INPUT + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_URL] == TEST_NEW_URL + + async def test_options_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry,