feat(media_player): source list + native select_source from the app list (t47 A)

The last piece a universal-media-player replacement needs: a dropdown of the apps,
an answer to what is on screen, and a way to pick one — all reading ONE app directory
(the user's Apps map, else DEFAULT_APP_DIRECTORY), so the dropdown can never offer a
name the launch path cannot resolve. That invariant is pinned by a test that walks
source_list and resolves every entry.

* `source_list` — friendly names in directory order (the user's map order, or the
  curated list), from a new `_app_directory` accessor that the browse tree now shares.
* `source` — the foreground app's resolved name via the existing `resolve_app_name`
  chain (map → cached device-manifest label → raw package), i.e. exactly `app_name`,
  so the media player and the Current App sensor cannot disagree.
* `select_source(name)` — a configured select_source script still takes precedence
  (t44, unchanged: selecting a source can be more than launching an app); without one
  the name is resolved back to its package and launched over the shared
  `play_media("app", …)` path, so the native semantics are the ones play_media already
  had. Accepts a raw package id (what the browse tree publishes) and matches names
  case-insensitively; an unknown or empty name raises
  ServiceValidationError(translation_domain="nvidia_shield",
  translation_key="unknown_source", translation_placeholders={"source": …}) — a
  translated error, never a silent no-op — with new copy in BOTH strings files.
* SELECT_SOURCE is advertised when EITHER a select_source script is configured OR
  source_list is non-empty; VOLUME_SET stays script-only (the protocol has no
  absolute-volume key), and the two flags are pinned as independent.

Two t44 tests were updated because this is a deliberate behaviour change: "no native
path" is now only true of volume_set, and SELECT_SOURCE is advertised by default
because the curated directory always offers something.

Also: rewriting the strings files re-indented the seven script labels added in t44
(they were valid JSON but sat two levels shallow); the diff there is that fix plus
the new key.

Verified: 441 passed / 2 skipped, ruff clean, drift engine exit 0 under real HA
(23 modules, 59 imports, 157 relative imports, 36 call arguments, 7 awaited HA calls).
This commit is contained in:
James Mackay committed 2026-10-11 07:05:57 +01:00
1 parent b3f897c36e
commit b2aab15ebf
6 files changed
+326 -44

No files matched your search

+25 -18
View File
@@ -172,14 +172,16 @@ class TestUnmappedCommands:
assert player._manager._poller.media_calls == ["volume_up"]
async def test_a_command_with_no_native_path_stays_a_no_op(self, modules):
"""volume_set/select_source exist ONLY through a script — never a crash."""
async def test_volume_set_without_a_script_stays_a_no_op(self, modules):
"""Absolute volume exists ONLY through a script (the protocol has no
VOLUME_SET key), so unconfigured it sends nothing — never a crash."""
player, served = _player(modules, {})
await player.async_set_volume_level(0.4)
await player.async_select_source("TiviMate")
assert served == []
assert player._manager._protocol.key_calls == []
assert player._manager._poller.media_calls == []
async def test_other_commands_are_unaffected_by_a_mapped_one(self, modules):
from nsh.const import PROTOCOL_REMOTE_KEYS
@@ -257,30 +259,35 @@ class TestFeatureSet:
def _features(self, player):
return int(player.supported_features)
def test_volume_set_and_select_source_appear_only_when_configured(self, modules):
def test_volume_set_appears_only_when_configured(self, modules):
"""volume_set has no native path at all, so the script is the only reach."""
from homeassistant.components.media_player import MediaPlayerEntityFeature
player, _served = _player(modules, {})
baseline = self._features(player)
assert baseline & int(MediaPlayerEntityFeature.VOLUME_SET) == 0
player._entry.options[CONF_COMMAND_SCRIPTS] = {"volume_set": WATCH}
with_volume = self._features(player)
player._entry.options[CONF_COMMAND_SCRIPTS] = {
"volume_set": WATCH,
"select_source": WATCH,
}
with_both = self._features(player)
extra = (
MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.SELECT_SOURCE
)
assert with_volume & int(MediaPlayerEntityFeature.VOLUME_SET)
assert not with_volume & int(MediaPlayerEntityFeature.SELECT_SOURCE)
assert with_both & int(extra) == int(extra)
# nothing else moved, and unset means the feature set it always had
assert with_both & ~int(extra) == baseline
assert baseline & int(extra) == 0
# ...and nothing else moved
assert with_volume & ~int(MediaPlayerEntityFeature.VOLUME_SET) == baseline
def test_select_source_is_advertised_once_there_is_something_to_offer(
self, modules
):
"""t47: either a script OR a source list is enough — the native path exists
now, so an unconfigured integration still has sources to offer."""
from homeassistant.components.media_player import MediaPlayerEntityFeature
player, _served = _player(modules, {})
flagged = self._features(player)
assert flagged & int(MediaPlayerEntityFeature.SELECT_SOURCE)
# a configured script changes nothing about the flag
player._entry.options[CONF_COMMAND_SCRIPTS] = {"select_source": WATCH}
assert self._features(player) & int(MediaPlayerEntityFeature.SELECT_SOURCE)
def test_a_script_for_an_already_advertised_command_changes_nothing(self, modules):
player, _served = _player(modules, {})