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:
parent
d2d39bce3a
commit
4d34350f66
8 changed files with 176 additions and 5 deletions
|
@ -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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
|
@ -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]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Add table
Reference in a new issue