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:
Chris Talkington 2024-10-13 12:41:51 -05:00 committed by GitHub
parent f47a012c62
commit cb1e5a2412
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 138 additions and 30 deletions

View file

@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
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
PLATFORMS = [
@ -24,7 +24,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
device_id = entry.entry_id
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()
@ -32,6 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
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):
hass.data[DOMAIN].pop(entry.entry_id)
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)

View file

@ -10,12 +10,17 @@ from rokuecp import Roku, RokuError
import voluptuous as vol
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.core import HomeAssistant, callback
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})
@ -155,3 +160,36 @@ class RokuConfigFlow(ConfigFlow, domain=DOMAIN):
title=self.discovery_info[CONF_NAME],
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,
}
),
)

View file

@ -15,3 +15,9 @@ DEFAULT_PORT = 8060
# Services
SERVICE_SEARCH = "search"
# Config
CONF_PLAY_MEDIA_APP_ID = "play_media_app_id"
# Defaults
DEFAULT_PLAY_MEDIA_APP_ID = "15985"

View file

@ -29,15 +29,12 @@ class RokuDataUpdateCoordinator(DataUpdateCoordinator[Device]):
roku: Roku
def __init__(
self,
hass: HomeAssistant,
*,
host: str,
device_id: str,
self, hass: HomeAssistant, *, host: str, device_id: str, play_media_app_id: str
) -> None:
"""Initialize global Roku data updater."""
self.device_id = device_id
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.last_full_update = None

View file

@ -445,17 +445,25 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity):
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}:
params = {
param: extra[attr]
for (attr, param) in ATTRS_TO_PLAY_ON_ROKU_PARAMS.items()
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:
_LOGGER.error("Media type %s is not supported", original_media_type)
return

View file

@ -24,6 +24,18 @@
"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": {
"binary_sensor": {
"headphones_connected": {

View file

@ -6,7 +6,7 @@ from unittest.mock import MagicMock
import pytest
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.const import CONF_HOST, CONF_NAME, CONF_SOURCE
from homeassistant.core import HomeAssistant
@ -254,3 +254,25 @@ async def test_ssdp_discovery(
assert result["data"]
assert result["data"][CONF_HOST] == HOST
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",
}

View file

@ -32,6 +32,7 @@ from homeassistant.components.roku.const import (
ATTR_FORMAT,
ATTR_KEYWORD,
ATTR_MEDIA_TYPE,
DEFAULT_PLAY_MEDIA_APP_ID,
DOMAIN,
SERVICE_SEARCH,
)
@ -495,7 +496,7 @@ async def test_services_play_media(
blocking=True,
)
assert mock_roku.play_on_roku.call_count == 0
assert mock_roku.launch.call_count == 0
await hass.services.async_call(
MP_DOMAIN,
@ -509,7 +510,7 @@ async def test_services_play_media(
blocking=True,
)
assert mock_roku.play_on_roku.call_count == 0
assert mock_roku.launch.call_count == 0
@pytest.mark.parametrize(
@ -546,9 +547,10 @@ async def test_services_play_media_audio(
},
blocking=True,
)
mock_roku.play_on_roku.assert_called_once_with(
content_id,
mock_roku.launch.assert_called_once_with(
DEFAULT_PLAY_MEDIA_APP_ID,
{
"u": content_id,
"t": "a",
"songName": resolved_name,
"songFormat": resolved_format,
@ -591,9 +593,11 @@ async def test_services_play_media_video(
},
blocking=True,
)
mock_roku.play_on_roku.assert_called_once_with(
content_id,
mock_roku.launch.assert_called_once_with(
DEFAULT_PLAY_MEDIA_APP_ID,
{
"u": content_id,
"t": "v",
"videoName": resolved_name,
"videoFormat": resolved_format,
},
@ -617,10 +621,12 @@ async def test_services_camera_play_stream(
blocking=True,
)
assert mock_roku.play_on_roku.call_count == 1
mock_roku.play_on_roku.assert_called_with(
"https://awesome.tld/api/hls/api_token/master_playlist.m3u8",
assert mock_roku.launch.call_count == 1
mock_roku.launch.assert_called_with(
DEFAULT_PLAY_MEDIA_APP_ID,
{
"u": "https://awesome.tld/api/hls/api_token/master_playlist.m3u8",
"t": "v",
"videoName": "Camera Stream",
"videoFormat": "hls",
},
@ -653,14 +659,21 @@ async def test_services_play_media_local_source(
blocking=True,
)
assert mock_roku.play_on_roku.call_count == 1
assert mock_roku.play_on_roku.call_args
call_args = mock_roku.play_on_roku.call_args.args
assert "/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[0]
assert call_args[1] == {
"videoFormat": "mp4",
"videoName": "media-source://media_source/local/Epic Sax Guy 10 Hours.mp4",
}
assert mock_roku.launch.call_count == 1
assert mock_roku.launch.call_args
call_args = mock_roku.launch.call_args.args
assert call_args[0] == DEFAULT_PLAY_MEDIA_APP_ID
assert "u" in call_args[1]
assert "/local/Epic%20Sax%20Guy%2010%20Hours.mp4?authSig=" in call_args[1]["u"]
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)