feat(media_player): BrowseMedia app directory — root lists the options apps map (curated TiviMate/Wholphin/Netflix/YouTube fallback), selection launches via play_media(app) (P4-2c)

This commit is contained in:
James Mackay committed 2026-10-08 11:12:37 +01:00
1 parent 034cb5c83d
commit 326f02cb59
3 files changed
+252

No files matched your search

@@ -57,6 +57,7 @@ from .const import (
CONF_POWER_ENTITY, CONF_POWER_ENTITY,
CONF_RECEIVER_ENTITY, CONF_RECEIVER_ENTITY,
CONF_RECEIVER_INPUT, CONF_RECEIVER_INPUT,
DEFAULT_APP_DIRECTORY,
DOMAIN, DOMAIN,
PROTOCOL_MEDIA_KEYS, PROTOCOL_MEDIA_KEYS,
PROTOCOL_REMOTE_KEYS, PROTOCOL_REMOTE_KEYS,
@@ -85,6 +86,7 @@ _BASE_FEATURES = (
| MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_STEP
| MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.PLAY_MEDIA | MediaPlayerEntityFeature.PLAY_MEDIA
| MediaPlayerEntityFeature.BROWSE_MEDIA
) )
_PLAYBACK_FEATURES = ( _PLAYBACK_FEATURES = (
@@ -866,6 +868,37 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
return False return False
return await self._manager.bring_to_foreground(package) return await self._manager.bring_to_foreground(package)
# ── BrowseMedia app directory (P4-2c) ──
async def async_browse_media(self, media_content_type=None, media_content_id=None):
"""Root lists the apps map (or the curated default) → play_media(app)."""
from homeassistant.components.media_player import (
BrowseMediaSource,
MediaClass,
)
app_map = self._entry.options.get(CONF_APP_NAME_MAP) or DEFAULT_APP_DIRECTORY
children = [
BrowseMediaSource(
title=name,
media_class=MediaClass.APP,
media_content_type="app",
media_content_id=pkg,
can_play=True,
can_expand=False,
)
for pkg, name in app_map.items()
]
return BrowseMediaSource(
title="Apps",
media_class=MediaClass.DIRECTORY,
media_content_type="apps",
media_content_id="apps",
can_play=False,
can_expand=True,
children=children,
)
# ── play_media (§7): channel digits, app/url launch ── # ── play_media (§7): channel digits, app/url launch ──
async def async_play_media(self, media_type: str, media_id: str) -> None: async def async_play_media(self, media_type: str, media_id: str) -> None:
+34
View File
@@ -50,6 +50,7 @@ class MediaPlayerEntityFeature(enum.IntFlag):
VOLUME_MUTE = 8 VOLUME_MUTE = 8
VOLUME_STEP = 16 VOLUME_STEP = 16
VOLUME_SET = 32 VOLUME_SET = 32
BROWSE_MEDIA = 4096
TURN_ON = 64 TURN_ON = 64
TURN_OFF = 128 TURN_OFF = 128
PLAY_MEDIA = 256 PLAY_MEDIA = 256
@@ -57,6 +58,36 @@ class MediaPlayerEntityFeature(enum.IntFlag):
PREVIOUS_TRACK = 1024 PREVIOUS_TRACK = 1024
class MediaClass:
APP = "app"
DIRECTORY = "directory"
class BrowseMediaSource:
"""HA BrowseMedia shape used by the app directory tests."""
def __init__(
self,
*,
title,
media_class,
media_content_type,
media_content_id,
can_play,
can_expand,
children=None,
thumbnail=None,
):
self.title = title
self.media_class = media_class
self.media_content_type = media_content_type
self.media_content_id = media_content_id
self.can_play = can_play
self.can_expand = can_expand
self.children = children or []
self.thumbnail = thumbnail
class MediaEntityManager: class MediaEntityManager:
"""trivial stand-in for the HA platform internals we don't exercise.""" """trivial stand-in for the HA platform internals we don't exercise."""
@@ -120,6 +151,9 @@ def install() -> None:
mp.MediaPlayerEntity = MediaPlayerEntity mp.MediaPlayerEntity = MediaPlayerEntity
mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv") mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv")
mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature
mp.BrowseMedia = BrowseMediaSource # factory used as a constructor
mp.BrowseMediaSource = BrowseMediaSource
mp.MediaClass = MediaClass
mp.MediaPlayerState = MediaPlayerState mp.MediaPlayerState = MediaPlayerState
sys.modules["homeassistant.components.media_player"] = mp sys.modules["homeassistant.components.media_player"] = mp
+185
View File
@@ -613,3 +613,188 @@ def _remote_entity(modules):
async def _noop_async(*args, **kwargs): async def _noop_async(*args, **kwargs):
return None return None
class FakeServices:
"""hass.services stand-in recording registrations."""
def __init__(self):
self.registered: dict[tuple, object] = {}
self.service_calls: list[tuple] = []
def has_service(self, domain, service):
return (domain, service) in self.registered
def async_register(self, domain, service, func, schema=None):
self.registered[(domain, service)] = (func, schema)
def async_remove(self, domain, service):
self.registered.pop((domain, service), None)
class ServiceHarness:
"""FakeHass extension: `.services` + direct service invocation."""
def attach(self, player):
player.hass.services = FakeServices()
class TestAdbCommandService:
"""P4-2a: option-gated raw shell service on the entry."""
def _participant(self, modules, enabled, connected=True):
player, _s, _i = make_player(modules)
player._entry.options["adb_command_enabled"] = enabled
if not connected:
player._manager._poller = None
player.entity_id = "media_player.shield_test"
player._attr_unique_id = "uid_shield"
kind = entity_stublib.FakeHass()
player.hass = kind
kind.services = FakeServices()
return player, kind.services
async def test_option_off_does_not_participate(self, modules):
from nsh.media_player import _ADB_COMMAND_TARGETS
before = dict(_ADB_COMMAND_TARGETS)
player, _services = self._participant(modules, enabled=False)
# the add hook consults the entry option — off → no registration
if player._entry.options.get("adb_command_enabled", False):
_ADB_COMMAND_TARGETS.setdefault(player.entity_id, player._manager)
assert before == _ADB_COMMAND_TARGETS
async def test_option_on_registers_and_runs(self, modules):
player, services = self._participant(modules, enabled=True)
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
class Call:
data = {"command": "dumpsys power | head -5"}
shell_calls = []
async def _shell(cmd):
shell_calls.append(cmd)
return "ok"
player._manager._poller.shell = _shell
await _async_adb_command_service(Call())
assert shell_calls == ["dumpsys power | head -5"]
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
async def test_rejects_when_adb_unavailable(self, modules):
player, _s = self._participant(modules, enabled=True, connected=False)
from nsh.media_player import _ADB_COMMAND_TARGETS, _async_adb_command_service
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
class Call:
data = {"command": "echo hi"}
# no poller → the command is not sent and nothing raises
player._manager._poller = None
await _async_adb_command_service(Call())
# the manager is down (poller None) — ADB unavailable, no shell call;
# the handler logged a warning and returned without raising
assert player._manager._poller is None
_ADB_COMMAND_TARGETS.pop("media_player.shield_test", None)
async def test_unregister_on_last_participant_unload(self, modules):
from nsh.media_player import (
_ADB_COMMAND_TARGETS,
_adb_command_service_ensure,
_adb_command_service_maybe_remove,
)
mods = load_entity_modules()
player, _s, _i = make_player(mods)
player._entry.options["adb_command_enabled"] = True
player.entity_id = "media_player.shield_test"
player.hass.services = FakeServices()
_adb_command_service_ensure(player.hass)
_ADB_COMMAND_TARGETS["media_player.shield_test"] = player._manager
assert player.hass.services.has_service("nvidia_shield", "adb_command")
_ADB_COMMAND_TARGETS.pop("media_player.shield_test")
_adb_command_service_maybe_remove(player.hass)
assert not player.hass.services.has_service("nvidia_shield", "adb_command")
class TestEntryDiagnostics:
"""P4-2b: redacted entry diagnostics with store + transport health."""
async def test_sensitive_keys_redacted_and_health_present(self, modules):
import entity_stublib
mods = entity_stublib.load_entity_modules()
player, _s, _i = make_player(mods)
from types import SimpleNamespace as DiagEntry
entry = DiagEntry(
data={},
options={
"adb_command_enabled": True,
"protocol_pairing_code": "4821",
},
title="Shield",
)
manager = player._manager
manager.store.set("awake", True)
entry.runtime_data = manager
from nsh.media_player import async_get_config_entry_diagnostics
payload = await async_get_config_entry_diagnostics(None, entry)
assert payload["entry"]["options"]["protocol_pairing_code"] == "**REDACTED**"
assert payload["entry"]["options"]["adb_command_enabled"] is True
assert "awake" in payload["store"]
assert payload["transport"]["adb"]["connected"] is not None
assert payload["transport"]["protocol"]["active"] is not None
class TestBrowseMediaDirectory:
"""P4-2c: the app directory root and its launch path."""
async def test_root_lists_the_options_map(self, modules):
player, _s, _i = make_player(modules)
player._entry.options["app_name_map"] = {
"ar.tvplayer.tv": "TiviMate",
"com.netflix.ninja": "Netflix",
}
root = await player.async_browse_media()
assert root.title == "Apps"
assert root.can_play is False and root.can_expand is True
ids = [(c.media_content_id, c.title) for c in root.children]
assert ids == [
("ar.tvplayer.tv", "TiviMate"),
("com.netflix.ninja", "Netflix"),
]
assert all(c.media_content_type == "app" for c in root.children)
async def test_root_falls_back_to_the_curated_default(self, modules):
player, _s, _i = make_player(modules) # no apps map configured
root = await player.async_browse_media()
ids = [c.media_content_id for c in root.children]
assert "ar.tvplayer.tv" in ids # TiviMate
assert "com.github.damontecres.wholphin" in ids # Wholphin
assert "com.netflix.ninja" in ids # Netflix
assert "com.google.android.youtube.tv" in ids # YouTube
async def test_selection_launches_via_play_media_app(self, modules):
player, _s, _i = make_player(modules)
protocol = player._manager._protocol
root = await player.async_browse_media()
selected = root.children[0]
await player.async_play_media("app", selected.media_content_id)
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH