feat(media-player): a switch Receiver is ensured on, and apps leave the browser (t61)

Two halves of the same live-run report.

RECEIVER AS A SWITCH. The user: "it would be nice if a receiver could just be a
switch, I don't need my volume scripts then because the receiver portion could
just make sure the receiver is on if it's a switch". `_ensure_receiver_on()`
runs at the top of the three playback entry points — turn_on, play_media,
select_source — and calls switch.turn_on only when the configured switch
receiver reports `off`: an `on` switch gets no call (idempotent), an
unavailable/unknown/missing state is left alone with a debug line rather than
guessed at, and a failing service call is logged, never raised — a receiver that
cannot be switched on must not stop the Shield from playing. Nothing else about
the switch is touched (no volume, no toggle), and nothing switches it from a
state read, a poll or a listener, so an off receiver stays off while the Shield
is idle. A media_player receiver keeps its behaviour exactly. select_source now
dispatches through the private `_dispatch_play_media`, so one user action looks
at the same switch once; `play_media` keeps its public signature and behaviour.
The Receiver field already accepted switch.* at the validation/strings layer
(t56, pinned by the real-HA tests); the strings copy now says what playback does
with it.

MEDIA BROWSER. The user: "Media Browser shows Apps. To me it should probably be
integrated with the home assistant media display, keep select sources for apps".
`async_browse_media` no longer publishes the app directory: it returns a valid,
standard BrowseMedia root (device title, DIRECTORY class, no content id, no
children) instead of a second, inconsistent copy of the list. Apps stay reachable
through source_list/source/select_source and play_media("app", <package>), which
is unchanged and complete; nothing else on this entity is browsable, so there is
no dead entry to select, and the drift gate's t37 D1 browse assertions still hold.

Tests: tests/test_receiver_switch.py pins all four promised states — off →
exactly one switch.turn_on; on → none; unavailable/unknown/missing/failing →
playback continues and nothing raises; media_player receiver → never switched —
plus the idle-Shield rule. The browse pins move from the removed tree to the
surviving surfaces (source_list, _source_package, _app_directory), and the harness
gains the real hass.states shape (FakeStates) and the real switch state constants.
README documents both changes; both strings files stay byte-identical.
This commit is contained in:
engineer committed 2026-10-12 08:34:53 +01:00
1 parent c884c7e1ab
commit 431b7cd8f4
8 files changed
+448 -133

No files matched your search

+48 -40
View File
@@ -118,7 +118,11 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
player._entity_tasks = set()
player._channel_task = None
player.async_write_ha_state = lambda: None
player._read = lambda eid: None
# t61: FakeHass carries the real `states` shape, so the entity's own `_read`
# is the one under test — an entity no test set still reads as None.
# t61: `__init__` gives the entity an (empty) power-sensor map; the harness
# mirrors it so the attribute payload can be read here too.
player._power_sensors = {}
# t41: nothing may leave this integration, so an HA service call is now the
# failure signal rather than a supported tier. `served` stays empty unless a
@@ -1455,9 +1459,10 @@ class TestFaithfulHaStubs:
class TestBrowseMediaDirectory:
"""P4-2c: the app directory root and its launch path."""
"""t61: the HA media browser publishes NO media — apps are sources."""
async def test_root_lists_the_options_map(self, modules):
async def test_the_browser_is_an_empty_root(self, modules):
"""The user's report: apps must not appear in the media browser."""
player, _s, _i = make_player(modules)
player._entry.options["app_name_map"] = {
"ar.tvplayer.tv": "TiviMate",
@@ -1466,53 +1471,56 @@ class TestBrowseMediaDirectory:
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)
# t37 D1: a standard media_player.BrowseMedia tree — explicit
# media_content_id/-type at every level, the directory naming the class
# of its children, and NO media_source `domain` (that parameter belongs
# to media_source.models.BrowseMediaSource, which also builds the
# media_source:// ids).
assert root.children == [] # not one app row
assert root.can_play is False
assert root.can_expand is False # there is nothing to open
assert root.title == "mdarcy" # the device, not a second app list
# t37 D1: still a standard media_player.BrowseMedia root — explicit
# class/type, NO media_source `domain` (that parameter belongs to
# media_source.models.BrowseMediaSource, which also builds the
# media_source:// ids and is not exportable from media_player at all).
assert root.media_class == "directory"
assert root.media_content_id == "apps"
assert root.media_content_type == "apps"
assert root.children_media_class == "app"
assert root.media_content_id is None # no node to select
assert root.children_media_class is None
assert not hasattr(root, "domain")
assert [c.media_class for c in root.children] == ["app", "app"]
# ...and it serialises into the payload the frontend consumes
payload = root.as_dict()
assert payload["media_content_id"] == "apps"
assert payload["children_media_class"] == "app"
assert payload["children"][0]["media_content_id"] == "ar.tvplayer.tv"
assert payload["children"][0]["media_content_type"] == "app"
assert payload["children"][0]["can_play"] is True
assert payload["children"] == []
assert payload["can_expand"] is False
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):
async def test_apps_stay_reachable_through_the_sources(self, modules):
"""What the browser no longer offers, source_list/select_source still do."""
player, _s, _i = make_player(modules)
protocol = player._manager._protocol
player._entry.options["app_name_map"] = {
"ar.tvplayer.tv": "TiviMate",
"com.netflix.ninja": "Netflix",
}
root = await player.async_browse_media()
selected = root.children[0]
await player.async_play_media("app", selected.media_content_id)
assert root.children == []
assert player.source_list == ["TiviMate", "Netflix"]
assert protocol.launch_calls == [selected.media_content_id] # C-LAUNCH
protocol = player._manager._protocol
await player.async_select_source("Netflix")
assert protocol.launch_calls == ["com.netflix.ninja"] # still launchable
async def test_any_rebrowse_id_stays_a_valid_empty_root(self, modules):
"""A frontend that re-browses an id cannot make this crash or lie."""
player, _s, _i = make_player(modules)
for content_type, content_id in (
(None, None),
("apps", "apps"),
("app", "ar.tvplayer.tv"),
("channel", "12"),
):
root = await player.async_browse_media(content_type, content_id)
assert root.children == []
assert root.media_content_id is None
class DownAdbPoller(FakeAdbPoller):