From 326f02cb599f4a562ddbf4a9fc27cb3dfe15d6a2 Mon Sep 17 00:00:00 2001 From: James Mackay Date: Thu, 8 Oct 2026 11:09:41 +0100 Subject: [PATCH] =?UTF-8?q?feat(media=5Fplayer):=20BrowseMedia=20app=20dir?= =?UTF-8?q?ectory=20=E2=80=94=20root=20lists=20the=20options=20apps=20map?= =?UTF-8?q?=20(curated=20TiviMate/Wholphin/Netflix/YouTube=20fallback),=20?= =?UTF-8?q?selection=20launches=20via=20play=5Fmedia(app)=20(P4-2c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nvidia_shield/media_player.py | 33 ++++ tests/entity_stublib.py | 34 ++++ tests/test_command_routing.py | 185 ++++++++++++++++++ 3 files changed, 252 insertions(+) diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index f151b05..6cd2180 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -57,6 +57,7 @@ from .const import ( CONF_POWER_ENTITY, CONF_RECEIVER_ENTITY, CONF_RECEIVER_INPUT, + DEFAULT_APP_DIRECTORY, DOMAIN, PROTOCOL_MEDIA_KEYS, PROTOCOL_REMOTE_KEYS, @@ -85,6 +86,7 @@ _BASE_FEATURES = ( | MediaPlayerEntityFeature.VOLUME_STEP | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.PLAY_MEDIA + | MediaPlayerEntityFeature.BROWSE_MEDIA ) _PLAYBACK_FEATURES = ( @@ -866,6 +868,37 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity): return False 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 ── async def async_play_media(self, media_type: str, media_id: str) -> None: diff --git a/tests/entity_stublib.py b/tests/entity_stublib.py index 8e873cf..15a0ac0 100644 --- a/tests/entity_stublib.py +++ b/tests/entity_stublib.py @@ -50,6 +50,7 @@ class MediaPlayerEntityFeature(enum.IntFlag): VOLUME_MUTE = 8 VOLUME_STEP = 16 VOLUME_SET = 32 + BROWSE_MEDIA = 4096 TURN_ON = 64 TURN_OFF = 128 PLAY_MEDIA = 256 @@ -57,6 +58,36 @@ class MediaPlayerEntityFeature(enum.IntFlag): 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: """trivial stand-in for the HA platform internals we don't exercise.""" @@ -120,6 +151,9 @@ def install() -> None: mp.MediaPlayerEntity = MediaPlayerEntity mp.MediaPlayerDeviceClass = types.SimpleNamespace(TV="tv") mp.MediaPlayerEntityFeature = MediaPlayerEntityFeature + mp.BrowseMedia = BrowseMediaSource # factory used as a constructor + mp.BrowseMediaSource = BrowseMediaSource + mp.MediaClass = MediaClass mp.MediaPlayerState = MediaPlayerState sys.modules["homeassistant.components.media_player"] = mp diff --git a/tests/test_command_routing.py b/tests/test_command_routing.py index 8ea865b..3c4b30d 100644 --- a/tests/test_command_routing.py +++ b/tests/test_command_routing.py @@ -613,3 +613,188 @@ def _remote_entity(modules): async def _noop_async(*args, **kwargs): 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