mirror of
https://github.com/home-assistant/core.git
synced 2025-07-10 23:07:09 +00:00
Fix playing media via roku (#128133)
* re-support playing media via roku * fixes * test fixes * Update test_media_player.py * always send media type * add description to options flow
This commit is contained in:
parent
f47a012c62
commit
cb1e5a2412
@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.const import CONF_HOST, Platform
|
from homeassistant.const import CONF_HOST, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN
|
||||||
from .coordinator import RokuDataUpdateCoordinator
|
from .coordinator import RokuDataUpdateCoordinator
|
||||||
|
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
@ -24,7 +24,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
device_id = entry.entry_id
|
device_id = entry.entry_id
|
||||||
|
|
||||||
coordinator = RokuDataUpdateCoordinator(
|
coordinator = RokuDataUpdateCoordinator(
|
||||||
hass, host=entry.data[CONF_HOST], device_id=device_id
|
hass,
|
||||||
|
host=entry.data[CONF_HOST],
|
||||||
|
device_id=device_id,
|
||||||
|
play_media_app_id=entry.options.get(
|
||||||
|
CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
@ -32,6 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
|
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@ -40,3 +47,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
return unload_ok
|
return unload_ok
|
||||||
|
|
||||||
|
|
||||||
|
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
|
"""Reload the config entry when it changed."""
|
||||||
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
@ -10,12 +10,17 @@ from rokuecp import Roku, RokuError
|
|||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components import ssdp, zeroconf
|
from homeassistant.components import ssdp, zeroconf
|
||||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import (
|
||||||
|
ConfigEntry,
|
||||||
|
ConfigFlow,
|
||||||
|
ConfigFlowResult,
|
||||||
|
OptionsFlowWithConfigEntry,
|
||||||
|
)
|
||||||
from homeassistant.const import CONF_HOST, CONF_NAME
|
from homeassistant.const import CONF_HOST, CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID, DOMAIN
|
||||||
|
|
||||||
DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
|
||||||
|
|
||||||
@ -155,3 +160,36 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
title=self.discovery_info[CONF_NAME],
|
title=self.discovery_info[CONF_NAME],
|
||||||
data=self.discovery_info,
|
data=self.discovery_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
) -> OptionsFlowWithConfigEntry:
|
||||||
|
"""Create the options flow."""
|
||||||
|
return RokuOptionsFlowHandler(config_entry)
|
||||||
|
|
||||||
|
|
||||||
|
class RokuOptionsFlowHandler(OptionsFlowWithConfigEntry):
|
||||||
|
"""Handle Roku options."""
|
||||||
|
|
||||||
|
async def async_step_init(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Manage Roku 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=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(
|
||||||
|
CONF_PLAY_MEDIA_APP_ID,
|
||||||
|
default=self.options.get(
|
||||||
|
CONF_PLAY_MEDIA_APP_ID, DEFAULT_PLAY_MEDIA_APP_ID
|
||||||
|
),
|
||||||
|
): str,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@ -15,3 +15,9 @@ DEFAULT_PORT = 8060
|
|||||||
|
|
||||||
# Services
|
# Services
|
||||||
SERVICE_SEARCH = "search"
|
SERVICE_SEARCH = "search"
|
||||||
|
|
||||||
|
# Config
|
||||||
|
CONF_PLAY_MEDIA_APP_ID = "play_media_app_id"
|
||||||
|
|
||||||
|
# Defaults
|
||||||
|
DEFAULT_PLAY_MEDIA_APP_ID = "15985"
|
||||||
|
@ -29,15 +29,12 @@ class RokuDataUpdateCoordinator(DataUpdateCoordinator[Device]):
|
|||||||
roku: Roku
|
roku: Roku
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self, hass: HomeAssistant, *, host: str, device_id: str, play_media_app_id: str
|
||||||
hass: HomeAssistant,
|
|
||||||
*,
|
|
||||||
host: str,
|
|
||||||
device_id: str,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize global Roku data updater."""
|
"""Initialize global Roku data updater."""
|
||||||
self.device_id = device_id
|
self.device_id = device_id
|
||||||
self.roku = Roku(host=host, session=async_get_clientsession(hass))
|
self.roku = Roku(host=host, session=async_get_clientsession(hass))
|
||||||
|
self.play_media_app_id = play_media_app_id
|
||||||
|
|
||||||
self.full_update_interval = timedelta(minutes=15)
|
self.full_update_interval = timedelta(minutes=15)
|
||||||
self.last_full_update = None
|
self.last_full_update = None
|
||||||
|
@ -445,17 +445,25 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
|
|||||||
if attr in extra
|
if attr in extra
|
||||||
}
|
}
|
||||||
|
|
||||||
params = {"t": "a", **params}
|
params = {"u": media_id, "t": "a", **params}
|
||||||
|
|
||||||
await self.coordinator.roku.play_on_roku(media_id, params)
|
await self.coordinator.roku.launch(
|
||||||
|
self.coordinator.play_media_app_id,
|
||||||
|
params,
|
||||||
|
)
|
||||||
elif media_type in {MediaType.URL, MediaType.VIDEO}:
|
elif media_type in {MediaType.URL, MediaType.VIDEO}:
|
||||||
params = {
|
params = {
|
||||||
param: extra[attr]
|
param: extra[attr]
|
||||||
for (attr, param) in ATTRS_TO_PLAY_ON_ROKU_PARAMS.items()
|
for (attr, param) in ATTRS_TO_PLAY_ON_ROKU_PARAMS.items()
|
||||||
if attr in extra
|
if attr in extra
|
||||||
}
|
}
|
||||||
|
params["u"] = media_id
|
||||||
|
params["t"] = "v"
|
||||||
|
|
||||||
await self.coordinator.roku.play_on_roku(media_id, params)
|
await self.coordinator.roku.launch(
|
||||||
|
self.coordinator.play_media_app_id,
|
||||||
|
params,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
_LOGGER.error("Media type %s is not supported", original_media_type)
|
_LOGGER.error("Media type %s is not supported", original_media_type)
|
||||||
return
|
return
|
||||||
|
@ -24,6 +24,18 @@
|
|||||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"options": {
|
||||||
|
"step": {
|
||||||
|
"init": {
|
||||||
|
"data": {
|
||||||
|
"play_media_app_id": "Play Media Roku Application ID"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"play_media_app_id": "The application ID to use when launching media playback. Must support the PlayOnRoku API."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
"binary_sensor": {
|
"binary_sensor": {
|
||||||
"headphones_connected": {
|
"headphones_connected": {
|
||||||
|
@ -6,7 +6,7 @@ from unittest.mock import MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
from rokuecp import RokuConnectionError
|
from rokuecp import RokuConnectionError
|
||||||
|
|
||||||
from homeassistant.components.roku.const import DOMAIN
|
from homeassistant.components.roku.const import CONF_PLAY_MEDIA_APP_ID, DOMAIN
|
||||||
from homeassistant.config_entries import SOURCE_HOMEKIT, SOURCE_SSDP, SOURCE_USER
|
from homeassistant.config_entries import SOURCE_HOMEKIT, SOURCE_SSDP, SOURCE_USER
|
||||||
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE
|
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@ -254,3 +254,25 @@ async def test_ssdp_discovery(
|
|||||||
assert result["data"]
|
assert result["data"]
|
||||||
assert result["data"][CONF_HOST] == HOST
|
assert result["data"][CONF_HOST] == HOST
|
||||||
assert result["data"][CONF_NAME] == UPNP_FRIENDLY_NAME
|
assert result["data"][CONF_NAME] == UPNP_FRIENDLY_NAME
|
||||||
|
|
||||||
|
|
||||||
|
async def test_options_flow(
|
||||||
|
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||||
|
) -> None:
|
||||||
|
"""Test options config flow."""
|
||||||
|
mock_config_entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
|
||||||
|
|
||||||
|
assert result.get("type") is FlowResultType.FORM
|
||||||
|
assert result.get("step_id") == "init"
|
||||||
|
|
||||||
|
result2 = await hass.config_entries.options.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
user_input={CONF_PLAY_MEDIA_APP_ID: "782875"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result2.get("type") is FlowResultType.CREATE_ENTRY
|
||||||
|
assert result2.get("data") == {
|
||||||
|
CONF_PLAY_MEDIA_APP_ID: "782875",
|
||||||
|
}
|
||||||
|
@ -32,6 +32,7 @@ from homeassistant.components.roku.const import (
|
|||||||
ATTR_FORMAT,
|
ATTR_FORMAT,
|
||||||
ATTR_KEYWORD,
|
ATTR_KEYWORD,
|
||||||
ATTR_MEDIA_TYPE,
|
ATTR_MEDIA_TYPE,
|
||||||
|
DEFAULT_PLAY_MEDIA_APP_ID,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SERVICE_SEARCH,
|
SERVICE_SEARCH,
|
||||||
)
|
)
|
||||||
@ -495,7 +496,7 @@ async def test_services_play_media(
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert mock_roku.play_on_roku.call_count == 0
|
assert mock_roku.launch.call_count == 0
|
||||||
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
MP_DOMAIN,
|
MP_DOMAIN,
|
||||||
@ -509,7 +510,7 @@ async def test_services_play_media(
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert mock_roku.play_on_roku.call_count == 0
|
assert mock_roku.launch.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@ -546,9 +547,10 @@ async def test_services_play_media_audio(
|
|||||||
},
|
},
|
||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
mock_roku.play_on_roku.assert_called_once_with(
|
mock_roku.launch.assert_called_once_with(
|
||||||
content_id,
|
DEFAULT_PLAY_MEDIA_APP_ID,
|
||||||
{
|
{
|
||||||
|
"u": content_id,
|
||||||
"t": "a",
|
"t": "a",
|
||||||
"songName": resolved_name,
|
"songName": resolved_name,
|
||||||
"songFormat": resolved_format,
|
"songFormat": resolved_format,
|
||||||
@ -591,9 +593,11 @@ async def test_services_play_media_video(
|
|||||||
},
|
},
|
||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
mock_roku.play_on_roku.assert_called_once_with(
|
mock_roku.launch.assert_called_once_with(
|
||||||
content_id,
|
DEFAULT_PLAY_MEDIA_APP_ID,
|
||||||
{
|
{
|
||||||
|
"u": content_id,
|
||||||
|
"t": "v",
|
||||||
"videoName": resolved_name,
|
"videoName": resolved_name,
|
||||||
"videoFormat": resolved_format,
|
"videoFormat": resolved_format,
|
||||||
},
|
},
|
||||||
@ -617,10 +621,12 @@ async def test_services_camera_play_stream(
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert mock_roku.play_on_roku.call_count == 1
|
assert mock_roku.launch.call_count == 1
|
||||||
mock_roku.play_on_roku.assert_called_with(
|
mock_roku.launch.assert_called_with(
|
||||||
"https://awesome.tld/api/hls/api_token/master_playlist.m3u8",
|
DEFAULT_PLAY_MEDIA_APP_ID,
|
||||||
{
|
{
|
||||||
|
"u": "https://awesome.tld/api/hls/api_token/master_playlist.m3u8",
|
||||||
|
"t": "v",
|
||||||
"videoName": "Camera Stream",
|
"videoName": "Camera Stream",
|
||||||
"videoFormat": "hls",
|
"videoFormat": "hls",
|
||||||
},
|
},
|
||||||
@ -653,14 +659,21 @@ async def test_services_play_media_local_source(
|
|||||||
blocking=True,
|
blocking=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert mock_roku.play_on_roku.call_count == 1
|
assert mock_roku.launch.call_count == 1
|
||||||
assert mock_roku.play_on_roku.call_args
|
assert mock_roku.launch.call_args
|
||||||
call_args = mock_roku.play_on_roku.call_args.args
|
call_args = mock_roku.launch.call_args.args
|
||||||
assert "/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[0]
|
assert call_args[0] == DEFAULT_PLAY_MEDIA_APP_ID
|
||||||
assert call_args[1] == {
|
assert "u" in call_args[1]
|
||||||
"videoFormat": "mp4",
|
assert "/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[1]["u"]
|
||||||
"videoName": "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4",
|
assert "t" in call_args[1]
|
||||||
}
|
assert call_args[1]["t"] == "v"
|
||||||
|
assert "videoFormat" in call_args[1]
|
||||||
|
assert call_args[1]["videoFormat"] == "mp4"
|
||||||
|
assert "videoName" in call_args[1]
|
||||||
|
assert (
|
||||||
|
call_args[1]["videoName"]
|
||||||
|
== "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True)
|
@pytest.mark.parametrize("mock_device", ["roku/rokutv-7820x.json"], indirect=True)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user