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:
1 parent
c884c7e1ab
commit
431b7cd8f4
8 files changed
+448
-133
No files matched your search
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user