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:
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_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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user