fix(media-player): the browse root carries a real string id (t63)

The live report: "Browse media seems to spin and never finish loading". The cause is
in what async_browse_media HANDS BACK, not in the method — it returns with no awaits,
so nothing inside it can hang. The root node's media_content_id was None, and HA core
never serves a node without a string id: across the 43 BrowseMedia constructions in
the installed core media_player platforms, zero pass None (the roots use a
literal/module constant — arcam_fmj's "root", "apps", BROWSE_ROOT,
TUNER_PRESETS_ROOT — or "" for a pure container). The media-browser frontend keys the
node it is loading on that id, so a null id left the dialog re-requesting the same
node for ever.

BROWSE_ROOT_ID = "root" is now what the entity serves for EVERY requested id — the
root id, None, or anything a stale frontend asks for — so a repeat request is
idempotent rather than a loop.

The type was reassessed as well: MediaType.APPS (t61's choice) is what core pairs
with children=[...] — a folder OF apps — so on a zero-child node it invited the
frontend to treat it as an unloaded collection. No MediaType member means "a
directory with nothing in it", so the node carries the class it already declares
(MediaClass.DIRECTORY -> "directory"), which is exactly what can_expand=False and
children=[] say.

Verified over the real path the frontend uses — websocket_browse_media ->
player.async_browse_media -> payload.as_dict() — by driving HA's own decorated
handler against a real entity (tests/test_ha_api_drift.py, which the CI drift job
runs with Home Assistant installed): the root is a genuine BrowseMedia, its as_dict()
survives json.dumps, the id is the string "root", and browse(root_id) == browse(None)
in shape. Red proof on the pre-fix source: the new real-HA test fails at
`assert isinstance(payload["media_content_id"], str)` -> `isinstance(None, str)`.

The frontend bundle is not installed here (no hass_frontend) and there is no browser
to drive, so the rendered dialog is NOT exercised: this pins the backend half of that
path, end to end through the handler's decorators.
This commit is contained in:
engineer committed 2026-10-12 09:42:07 +01:00
1 parent 5ae2baa618
commit eb62011ded
3 files changed
+196 -17

No files matched your search

+23 -10
View File
@@ -1459,10 +1459,10 @@ class TestFaithfulHaStubs:
class TestBrowseMediaDirectory:
"""t61: the HA media browser publishes NO media — apps are sources."""
"""t61/t63: the HA media browser publishes NO media — and says so unambiguously."""
async def test_the_browser_is_an_empty_root(self, modules):
"""The user's report: apps must not appear in the media browser."""
async def test_the_browser_is_a_childless_root_with_a_real_id(self, modules):
"""t63: the null id is what made the live browse dialog spin for ever."""
player, _s, _i = make_player(modules)
player._entry.options["app_name_map"] = {
"ar.tvplayer.tv": "TiviMate",
@@ -1475,19 +1475,26 @@ class TestBrowseMediaDirectory:
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
# t63: the id has to be a real STRING. HA core never serves a node without
# one, and the frontend keys the node it is loading on that id — a None id
# left the dialog re-requesting the same node for ever (the live report).
assert isinstance(root.media_content_id, str) and root.media_content_id
assert root.media_content_id == "root"
# ...and the type must not claim a collection this node does not have:
# core pairs MediaType.APPS with children=[...] — a folder OF apps.
assert root.media_content_type == "directory"
# 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_type == "apps"
assert root.media_content_id is None # no node to select
assert root.children_media_class is None
assert not hasattr(root, "domain")
# ...and it serialises into the payload the frontend consumes
payload = root.as_dict()
assert payload["children"] == []
assert payload["can_expand"] is False
assert payload["media_content_id"] == "root"
async def test_apps_stay_reachable_through_the_sources(self, modules):
"""What the browser no longer offers, source_list/select_source still do."""
@@ -1506,21 +1513,27 @@ class TestBrowseMediaDirectory:
await player.async_select_source("Netflix")
assert protocol.launch_calls == ["com.netflix.ninja"] # still launchable
async def test_every_requested_id_answers_the_same_leaf(self, modules):
"""A repeat request must be idempotent — not a loop the frontend can ride.
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."""
The handler ignores both arguments, so the root id, ``None`` and a stale
child id all return the same node: the frontend cannot bounce between
"loading" and "loaded" for one id.
"""
player, _s, _i = make_player(modules)
first = (await player.async_browse_media()).as_dict()
for content_type, content_id in (
(None, None),
("apps", "root"),
("apps", "apps"),
("app", "ar.tvplayer.tv"),
("channel", "12"),
):
root = await player.async_browse_media(content_type, content_id)
repeat = await player.async_browse_media(content_type, content_id)
assert root.children == []
assert root.media_content_id is None
assert repeat.as_dict() == first
class DownAdbPoller(FakeAdbPoller):