fix(media_player): D1 — browse builds real BrowseMedia nodes, not BrowseMediaSource (t37)

`homeassistant.components.media_player` has not exported `BrowseMediaSource`
for years: the class lives in `homeassistant.components.media_source.models`,
where it exists to build `media_source://<domain>` ids and is the only place a
`domain` parameter belongs (t30 G18's note was about THAT class). The local
import therefore raised ImportError, and with it every real browse failed —
while the suite stayed green because the harness stubbed a `BrowseMediaSource`
that real HA does not expose from there.

`async_browse_media` now builds the standard tree exactly as core's own
androidtv_remote/media_player.py does: `BrowseMedia` imported from
homeassistant.components.media_player at module scope, every node carrying an
explicit media_content_id/media_content_type (MediaType.APP/APPS),
`children_media_class=MediaClass.APP` on the directory, and no `domain`.
Verified against a real install: the old import raises ImportError, the old
`domain=` kwarg raises TypeError, and the new node constructs.

The harness is now faithful so this cannot hide again:
* `entity_stublib` exposes a `BrowseMedia` with the real keyword-only
  signature, `as_dict()` and `calculate_children_class()`, plus `MediaType`,
  and deliberately NOT `BrowseMediaSource` — so both the drifted import and the
  drifted kwarg fail in-suite;
* the app-directory test asserts the standard shape (classes, explicit ids and
  types, children_media_class, the serialised payload) instead of the removed
  `domain` attribute;
* TestFaithfulHaStubs pins the harness contract on the AST (no production
  module imports BrowseMediaSource) rather than on prose.

Verified: 284 passed, 2 skipped (D1 stage); ruff clean. Full gate re-run on the
completed branch: 289 passed / 2 skipped and 290 passed / 1 skipped.
This commit is contained in:
James Mackay committed 2026-10-10 21:19:53 +01:00
1 parent 5ede4fe722
commit 37b2accfda
3 files changed
+160 -28

No files matched your search

+74 -11
View File
@@ -64,35 +64,94 @@ class MediaPlayerEntityFeature(enum.IntFlag):
class MediaClass:
"""homeassistant.components.media_player.MediaClass (values we use)."""
APP = "app"
DIRECTORY = "directory"
class BrowseMediaSource:
"""HA BrowseMedia shape used by the app directory tests."""
class MediaType:
"""homeassistant.components.media_player.MediaType (values we use)."""
APP = "app"
APPS = "apps"
class BrowseMedia:
"""The REAL media_player.BrowseMedia shape (t37 D1).
Keyword-only with the six arguments real HA requires, the optional ones it
accepts, a faithful ``as_dict`` and — crucially — NO ``domain`` parameter:
``BrowseMediaSource`` lives in
``homeassistant.components.media_source.models`` (its contract is the
``media_source://<domain>`` id) and is NOT importable from
``homeassistant.components.media_player`` at all. The old import and the
old ``domain=`` kwarg therefore fail IN-SUITE instead of hiding behind a
permissive stub.
"""
def __init__(
self,
*,
title,
media_class,
media_content_type,
media_content_id,
media_content_type,
title,
can_play,
can_expand,
domain=None, # t30 G18 (2026-05-20 blog contract)
children=None,
children_media_class=None,
thumbnail=None,
not_shown=0,
can_search=False,
search_media_classes=None,
):
self.title = title
self.media_class = media_class
self.media_content_type = media_content_type
self.media_content_id = media_content_id
self.media_content_type = media_content_type
self.title = title
self.can_play = can_play
self.can_expand = can_expand
self.domain = domain
self.children = children or []
self.children = children
self.children_media_class = children_media_class
self.thumbnail = thumbnail
self.not_shown = not_shown
self.can_search = can_search
self.search_media_classes = search_media_classes
def as_dict(self, *, parent=True):
"""Real payload conversion — what the frontend actually consumes."""
if self.children_media_class is None and self.children:
self.calculate_children_class()
response = {
"title": self.title,
"media_class": self.media_class,
"media_content_type": self.media_content_type,
"media_content_id": self.media_content_id,
"children_media_class": self.children_media_class,
"can_play": self.can_play,
"can_expand": self.can_expand,
"can_search": self.can_search,
"search_media_classes": self.search_media_classes,
"thumbnail": self.thumbnail,
}
if not parent:
return response
response["not_shown"] = self.not_shown
response["children"] = (
[child.as_dict(parent=False) for child in self.children]
if self.children
else []
)
return response
def calculate_children_class(self):
"""Real helper: the children's common class, else DIRECTORY."""
self.children_media_class = MediaClass.DIRECTORY
assert self.children is not None
proposed_class = self.children[0].media_class
if all(child.media_class == proposed_class for child in self.children):
self.children_media_class = proposed_class
class MediaEntityManager:
@@ -278,9 +337,12 @@ 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.BrowseMedia = BrowseMedia
mp.MediaClass = MediaClass
mp.MediaType = MediaType
# NOTE (t37 D1): homeassistant.components.media_player exposes NO
# BrowseMediaSource — deliberately not stubbed, so the drifted import
# fails here exactly as it does in a real install.
mp.MediaPlayerState = MediaPlayerState
sys.modules["homeassistant.components.media_player"] = mp
@@ -347,6 +409,7 @@ def install() -> None:
entity_platform = types.ModuleType("homeassistant.helpers.entity_platform")
entity_platform.AddEntitiesCallback = object
helpers.entity_platform = entity_platform
sys.modules["homeassistant.helpers.event"] = event
sys.modules["homeassistant.helpers.entity"] = entity
sys.modules["homeassistant.helpers.entity_platform"] = entity_platform