Add Jellyfin audio_codec optionflow (#113036)

* Fix #92419; Add Jellyfin audio_codec optionflow

* Use CONF_AUDIO_CODEC constant, clean up code based on suggestions

* Fixed typos

* Parameterize Tests

* Use parameterized test for jellyfin test media resolve

* Apply suggestions from code review

* Update homeassistant/components/jellyfin/config_flow.py

---------

Co-authored-by: Erik Montnemery <erik@montnemery.com>
This commit is contained in:
Dennis Lee 2024-05-15 09:11:11 -05:00 committed by GitHub
parent d2d39bce3a
commit 4d34350f66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 176 additions and 5 deletions

View file

@ -8,12 +8,18 @@ from typing import Any
import voluptuous as vol 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.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.util.uuid import random_uuid_hex from homeassistant.util.uuid import random_uuid_hex
from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input 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__) _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: def _generate_client_device_id() -> str:
"""Generate a random UUID4 string to identify ourselves.""" """Generate a random UUID4 string to identify ourselves."""
return random_uuid_hex() return random_uuid_hex()
@ -128,3 +139,31 @@ class JellyfinConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_show_form( return self.async_show_form(
step_id="reauth_confirm", data_schema=REAUTH_DATA_SCHEMA, errors=errors 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
),
)

View file

@ -14,6 +14,7 @@ COLLECTION_TYPE_MOVIES: Final = "movies"
COLLECTION_TYPE_MUSIC: Final = "music" COLLECTION_TYPE_MUSIC: Final = "music"
COLLECTION_TYPE_TVSHOWS: Final = "tvshows" COLLECTION_TYPE_TVSHOWS: Final = "tvshows"
CONF_AUDIO_CODEC: Final = "audio_codec"
CONF_CLIENT_DEVICE_ID: Final = "client_device_id" CONF_CLIENT_DEVICE_ID: Final = "client_device_id"
DEFAULT_NAME: Final = "Jellyfin" DEFAULT_NAME: Final = "Jellyfin"
@ -50,6 +51,8 @@ SUPPORTED_COLLECTION_TYPES: Final = [
COLLECTION_TYPE_TVSHOWS, COLLECTION_TYPE_TVSHOWS,
] ]
SUPPORTED_AUDIO_CODECS: Final = ["aac", "mp3", "vorbis", "wma"]
PLAYABLE_ITEM_TYPES: Final = [ITEM_TYPE_AUDIO, ITEM_TYPE_EPISODE, ITEM_TYPE_MOVIE] PLAYABLE_ITEM_TYPES: Final = [ITEM_TYPE_AUDIO, ITEM_TYPE_EPISODE, ITEM_TYPE_MOVIE]

View file

@ -17,11 +17,13 @@ from homeassistant.components.media_source.models import (
MediaSourceItem, MediaSourceItem,
PlayMedia, PlayMedia,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .const import ( from .const import (
COLLECTION_TYPE_MOVIES, COLLECTION_TYPE_MOVIES,
COLLECTION_TYPE_MUSIC, COLLECTION_TYPE_MUSIC,
CONF_AUDIO_CODEC,
DOMAIN, DOMAIN,
ITEM_KEY_COLLECTION_TYPE, ITEM_KEY_COLLECTION_TYPE,
ITEM_KEY_ID, ITEM_KEY_ID,
@ -57,7 +59,7 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
entry = hass.config_entries.async_entries(DOMAIN)[0] entry = hass.config_entries.async_entries(DOMAIN)[0]
jellyfin_data: JellyfinData = hass.data[DOMAIN][entry.entry_id] 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): class JellyfinSource(MediaSource):
@ -65,11 +67,14 @@ class JellyfinSource(MediaSource):
name: str = "Jellyfin" 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.""" """Initialize the Jellyfin media source."""
super().__init__(DOMAIN) super().__init__(DOMAIN)
self.hass = hass self.hass = hass
self.entry = entry
self.client = client self.client = client
self.api = client.jellyfin self.api = client.jellyfin
@ -524,6 +529,8 @@ class JellyfinSource(MediaSource):
item_id = media_item[ITEM_KEY_ID] item_id = media_item[ITEM_KEY_ID]
if media_type == MEDIA_TYPE_AUDIO: 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] return self.api.audio_url(item_id) # type: ignore[no-any-return]
if media_type == MEDIA_TYPE_VIDEO: if media_type == MEDIA_TYPE_VIDEO:
return self.api.video_url(item_id) # type: ignore[no-any-return] return self.api.video_url(item_id) # type: ignore[no-any-return]

View file

@ -25,5 +25,14 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
} }
},
"options": {
"step": {
"init": {
"data": {
"audio_codec": "Audio codec"
}
}
}
} }
} }

View file

@ -144,6 +144,8 @@ def api_artwork_side_effect(*args, **kwargs):
def api_audio_url_side_effect(*args, **kwargs): def api_audio_url_side_effect(*args, **kwargs):
"""Handle variable responses for audio_url method.""" """Handle variable responses for audio_url method."""
item_id = args[0] 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" return f"http://localhost/Audio/{item_id}/universal?UserId=test-username,DeviceId=TEST-UUID,MaxStreamingBitrate=140000000"

View file

@ -1,4 +1,16 @@
# serializer version: 1 # 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 # name: test_movie_library
dict({ dict({
'can_expand': False, 'can_expand': False,

View file

@ -3,9 +3,14 @@
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from voluptuous.error import Invalid
from homeassistant import config_entries 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.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
@ -435,3 +440,57 @@ async def test_reauth_exception(
) )
assert result3["type"] is FlowResultType.ABORT assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "reauth_successful" 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

View file

@ -48,6 +48,10 @@ async def test_resolve(
assert play_media.mime_type == "audio/flac" assert play_media.mime_type == "audio/flac"
assert play_media.url == snapshot 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 # Test resolving a movie
mock_api.get_item.side_effect = None mock_api.get_item.side_effect = None
mock_api.get_item.return_value = load_json_fixture("movie.json") 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( async def test_root(
hass: HomeAssistant, hass: HomeAssistant,
mock_client: MagicMock, mock_client: MagicMock,