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

@@ -135,6 +135,28 @@ SERVICE_TO_STATE = {
#: input selection and attribute enrichment — and is never switched here.
RECEIVER_SWITCH_DOMAIN = "switch"
#: t63: the browse root's id and type — the FIX for the live report "Browse media
#: seems to spin and never finish loading".
#:
#: The id must be a real STRING. HA core never serves a node without one: 43
#: ``BrowseMedia`` constructions across the core media_player platforms all pass a
#: string (a literal or module constant — arcam_fmj's "root", "apps",
#: ``BROWSE_ROOT``, ``TUNER_PRESETS_ROOT`` — or "" for a pure container); zero
#: pass ``None``. The media-browser frontend keys the node it is loading on that
#: id, so ``media_content_id=None`` left the dialog re-requesting the same node for
#: ever. "root" is arcam_fmj's own browse-root id, and every requested id — this
#: one, ``None``, or anything a stale frontend asks for — answers the same node, so
#: a repeat request is idempotent rather than a loop.
BROWSE_ROOT_ID = "root"
#: The root's content type, and deliberately NOT ``MediaType.APPS``: core pairs
#: that type (with an "apps" id) with ``children=[…]`` — a folder OF apps — and this
#: node has no children at all, so the claim invites the frontend to treat an empty
#: node as an unloaded collection. No ``MediaType`` member means "a directory with
#: nothing in it", so the node carries the class it already declares, which is also
#: exactly what ``can_expand=False`` and ``children=[]`` say.
BROWSE_ROOT_TYPE = str(MediaClass.DIRECTORY)
_BASE_FEATURES = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
@@ -1444,7 +1466,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# ── BrowseMedia app directory (P4-2c) ──
async def async_browse_media(self, media_content_type=None, media_content_id=None):
"""The media browser is EMPTY on purpose — apps live in the sources (t61).
"""The media browser is EMPTY on purpose — and says so unambiguously.
The user's report, verbatim: "Media Browser shows Apps. To me it should
probably be integrated with the home assistant media display, keep
@@ -1458,16 +1480,24 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
NO children rather than a second, inconsistent copy of the app list:
``BrowseMedia`` (never ``BrowseMediaSource``, which media_player has not
exported since before t37 D1), the device as the root title, a directory
class, and no content id. There is no node to select, so no dead entry
can be offered and a click can never reach a row that cannot launch. An
integration with genuinely browsable media would publish it here; this
one has none.
class, and ``BROWSE_ROOT_ID``/``BROWSE_ROOT_TYPE``. There is no node to
select, so no dead entry can be offered and a click can never reach a row
that cannot launch. An integration with genuinely browsable media would
publish it here; this one has none.
t63 — and the id is a real STRING, which is the fix for the next live
report, "Browse media seems to spin and never finish loading". This
method returns with no awaits, so the hang was in what it handed back:
a null ``media_content_id``. HA core never serves one (see
``BROWSE_ROOT_ID``), because the frontend keys the node it is loading by
that id. Every requested id answers this same node, so a repeat request
is idempotent rather than a loop.
"""
return BrowseMedia(
title=self._entry.title or "NVIDIA Shield",
media_class=MediaClass.DIRECTORY,
media_content_type=MediaType.APPS,
media_content_id=None,
media_content_type=BROWSE_ROOT_TYPE,
media_content_id=BROWSE_ROOT_ID,
can_play=False,
can_expand=False,
children=[],