diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index 44374fb9399..4798a07b9cd 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -8,12 +8,18 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME +from homeassistant.core import callback from homeassistant.util.uuid import random_uuid_hex from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input -from .const import CONF_CLIENT_DEVICE_ID, DOMAIN +from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, SUPPORTED_AUDIO_CODECS _LOGGER = logging.getLogger(__name__) @@ -32,6 +38,11 @@ REAUTH_DATA_SCHEMA = vol.Schema( ) +OPTIONAL_DATA_SCHEMA = vol.Schema( + {vol.Optional("audio_codec"): vol.In(SUPPORTED_AUDIO_CODECS)} +) + + def _generate_client_device_id() -> str: """Generate a random UUID4 string to identify ourselves.""" return random_uuid_hex() @@ -128,3 +139,31 @@ class JellyfinConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="reauth_confirm", data_schema=REAUTH_DATA_SCHEMA, errors=errors ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Create the options flow.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(OptionsFlow): + """Handle an option flow for jellyfin.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + OPTIONAL_DATA_SCHEMA, self.config_entry.options + ), + ) diff --git a/homeassistant/components/jellyfin/const.py b/homeassistant/components/jellyfin/const.py index 764356e2ea6..34fb040115f 100644 --- a/homeassistant/components/jellyfin/const.py +++ b/homeassistant/components/jellyfin/const.py @@ -14,6 +14,7 @@ COLLECTION_TYPE_MOVIES: Final = "movies" COLLECTION_TYPE_MUSIC: Final = "music" COLLECTION_TYPE_TVSHOWS: Final = "tvshows" +CONF_AUDIO_CODEC: Final = "audio_codec" CONF_CLIENT_DEVICE_ID: Final = "client_device_id" DEFAULT_NAME: Final = "Jellyfin" @@ -50,6 +51,8 @@ SUPPORTED_COLLECTION_TYPES: Final = [ COLLECTION_TYPE_TVSHOWS, ] +SUPPORTED_AUDIO_CODECS: Final = ["aac", "mp3", "vorbis", "wma"] + PLAYABLE_ITEM_TYPES: Final = [ITEM_TYPE_AUDIO, ITEM_TYPE_EPISODE, ITEM_TYPE_MOVIE] diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index 6d982458378..a9eba7dc3a4 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -17,11 +17,13 @@ from homeassistant.components.media_source.models import ( MediaSourceItem, PlayMedia, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from .const import ( COLLECTION_TYPE_MOVIES, COLLECTION_TYPE_MUSIC, + CONF_AUDIO_CODEC, DOMAIN, ITEM_KEY_COLLECTION_TYPE, ITEM_KEY_ID, @@ -57,7 +59,7 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: entry = hass.config_entries.async_entries(DOMAIN)[0] jellyfin_data: JellyfinData = hass.data[DOMAIN][entry.entry_id] - return JellyfinSource(hass, jellyfin_data.jellyfin_client) + return JellyfinSource(hass, jellyfin_data.jellyfin_client, entry) class JellyfinSource(MediaSource): @@ -65,11 +67,14 @@ class JellyfinSource(MediaSource): name: str = "Jellyfin" - def __init__(self, hass: HomeAssistant, client: JellyfinClient) -> None: + def __init__( + self, hass: HomeAssistant, client: JellyfinClient, entry: ConfigEntry + ) -> None: """Initialize the Jellyfin media source.""" super().__init__(DOMAIN) self.hass = hass + self.entry = entry self.client = client self.api = client.jellyfin @@ -524,6 +529,8 @@ class JellyfinSource(MediaSource): item_id = media_item[ITEM_KEY_ID] if media_type == MEDIA_TYPE_AUDIO: + if audio_codec := self.entry.options.get(CONF_AUDIO_CODEC): + return self.api.audio_url(item_id, audio_codec=audio_codec) # type: ignore[no-any-return] return self.api.audio_url(item_id) # type: ignore[no-any-return] if media_type == MEDIA_TYPE_VIDEO: return self.api.video_url(item_id) # type: ignore[no-any-return] diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json index 3e4c8066b77..fd11d8fbad2 100644 --- a/homeassistant/components/jellyfin/strings.json +++ b/homeassistant/components/jellyfin/strings.json @@ -25,5 +25,14 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "init": { + "data": { + "audio_codec": "Audio codec" + } + } + } } } diff --git a/tests/components/jellyfin/conftest.py b/tests/components/jellyfin/conftest.py index ea46c669af7..4ef28a1cf20 100644 --- a/tests/components/jellyfin/conftest.py +++ b/tests/components/jellyfin/conftest.py @@ -144,6 +144,8 @@ def api_artwork_side_effect(*args, **kwargs): def api_audio_url_side_effect(*args, **kwargs): """Handle variable responses for audio_url method.""" item_id = args[0] + if audio_codec := kwargs.get("audio_codec"): + return f"http://localhost/Audio/{item_id}/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000,AudioCodec={audio_codec}" return f"http://localhost/Audio/{item_id}/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000" diff --git a/tests/components/jellyfin/snapshots/test_media_source.ambr b/tests/components/jellyfin/snapshots/test_media_source.ambr index 6d629f245a0..6f46aaf3f9b 100644 --- a/tests/components/jellyfin/snapshots/test_media_source.ambr +++ b/tests/components/jellyfin/snapshots/test_media_source.ambr @@ -1,4 +1,16 @@ # serializer version: 1 +# name: test_audio_codec_resolve[aac] + 'http://localhost/Audio/TRACK-UUID/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000,AudioCodec=aac' +# --- +# name: test_audio_codec_resolve[mp3] + 'http://localhost/Audio/TRACK-UUID/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000,AudioCodec=mp3' +# --- +# name: test_audio_codec_resolve[vorbis] + 'http://localhost/Audio/TRACK-UUID/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000,AudioCodec=vorbis' +# --- +# name: test_audio_codec_resolve[wma] + 'http://localhost/Audio/TRACK-UUID/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000,AudioCodec=wma' +# --- # name: test_movie_library dict({ 'can_expand': False, diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index b55766c2c68..c84a12d26a5 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -3,9 +3,14 @@ from unittest.mock import MagicMock import pytest +from voluptuous.error import Invalid from homeassistant import config_entries -from homeassistant.components.jellyfin.const import CONF_CLIENT_DEVICE_ID, DOMAIN +from homeassistant.components.jellyfin.const import ( + CONF_AUDIO_CODEC, + CONF_CLIENT_DEVICE_ID, + DOMAIN, +) from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -435,3 +440,57 @@ async def test_reauth_exception( ) assert result3["type"] is FlowResultType.ABORT assert result3["reason"] == "reauth_successful" + + +async def test_options_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test config flow options.""" + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + + assert config_entry.options == {} + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + # Audio Codec + # Default + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert CONF_AUDIO_CODEC not in config_entry.options + + # Bad + result = await hass.config_entries.options.async_init(config_entry.entry_id) + with pytest.raises(Invalid): + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_AUDIO_CODEC: "ogg"} + ) + + +@pytest.mark.parametrize( + "codec", + [("aac"), ("wma"), ("vorbis"), ("mp3")], +) +async def test_setting_codec( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, + codec: str, +) -> None: + """Test setting the audio_codec.""" + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_AUDIO_CODEC: codec} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert config_entry.options[CONF_AUDIO_CODEC] == codec diff --git a/tests/components/jellyfin/test_media_source.py b/tests/components/jellyfin/test_media_source.py index b8bbfea00d9..a57d51de1f1 100644 --- a/tests/components/jellyfin/test_media_source.py +++ b/tests/components/jellyfin/test_media_source.py @@ -48,6 +48,10 @@ async def test_resolve( assert play_media.mime_type == "audio/flac" assert play_media.url == snapshot + mock_api.audio_url.assert_called_with("TRACK-UUID") + assert mock_api.audio_url.call_count == 1 + mock_api.audio_url.reset_mock() + # Test resolving a movie mock_api.get_item.side_effect = None mock_api.get_item.return_value = load_json_fixture("movie.json") @@ -71,6 +75,42 @@ async def test_resolve( ) +@pytest.mark.parametrize( + "audio_codec", + [("aac"), ("wma"), ("vorbis"), ("mp3")], +) +async def test_audio_codec_resolve( + hass: HomeAssistant, + mock_client: MagicMock, + init_integration: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_api: MagicMock, + snapshot: SnapshotAssertion, + audio_codec: str, +) -> None: + """Test resolving Jellyfin media items with audio codec.""" + + # Test resolving a track + mock_api.get_item.side_effect = None + mock_api.get_item.return_value = load_json_fixture("track.json") + + result = await hass.config_entries.options.async_init(init_integration.entry_id) + await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"audio_codec": audio_codec} + ) + assert init_integration.options["audio_codec"] == audio_codec + + play_media = await async_resolve_media( + hass, f"{URI_SCHEME}{DOMAIN}/TRACK-UUID", "media_player.jellyfin_device" + ) + + assert play_media.mime_type == "audio/flac" + assert play_media.url == snapshot + + mock_api.audio_url.assert_called_with("TRACK-UUID", audio_codec=audio_codec) + assert mock_api.audio_url.call_count == 1 + + async def test_root( hass: HomeAssistant, mock_client: MagicMock,