feat(media-player): flatten now-playing into separate attributes and name the tier
The user reads attributes as YAML — "These could do with being separate attributes rather than an object in one?" — so the nested `media_dag_now_playing` object is gone and every field it carried is its own top-level attribute: now_playing_via, now_playing_state, now_playing_title, now_playing_artist, now_playing_album, now_playing_media_type, now_playing_series, now_playing_season, now_playing_episode, now_playing_duration, now_playing_position, now_playing_position_updated_at, now_playing_thumbnail, now_playing_media_info Rules the surface now holds to: - an unknown field is OMITTED, never published as None (`title: null` inside the object was exactly the noise this removes); 0 and False are real values - a device reading `off` publishes no now-playing row at all — a Jellyfin session that has gone stale must not put a title beside a device that is off — and `on` (nothing reported) publishes none either, because there is no reading - `now_playing_state` IS the entity's own state, so the ladder stays the single answer even while the foreground tier's last-known bridge serves a title - the tier indicator is `now_playing_via` (`jellyfin` / `media_session` / `app`), NOT `source`: on a media_player `source`/`source_list` mean the selected app input (`Netflix`, `TiviMate`, …) and they still do — a tier named `source` is what made the reading look like media metadata from somewhere else. The internal reading key is renamed `via` as well, and the Jellyfin tier now names itself too - every published name is declared unrecorded (G12): these change every poll COMPATIBILITY: `media_dag_now_playing` is removed, not deprecated, and no alias is kept — a nested copy would double every poll's payload. README documents the exact names, these rules, the sensor's own flat set and a one-line template migration. sensor.py: the Jellyfin now-playing sensor follows the same no-None rule (`title` and `media_type` used to be published as null there). Tests: 17 new cases pin the flattening, the tier rename, the five states, the off/on gate, the playing->idle transition, the last-known bridge, privacy mode and agreement with the Current App sensor; the tier-dict tests follow `via`.
This commit is contained in:
1 parent
46738063aa
commit
a1ce28996e
4 files changed
+543
-47
No files matched your search
@@ -114,6 +114,9 @@ def make_player(modules, manager=None, jellyfin_entity=None, options=None):
|
||||
player._intent = None
|
||||
player._intent_timer_unsub = None # mirrors __init__ (intent self-heal)
|
||||
player._last_channel = None
|
||||
# t65: the Jellyfin tier reads this; ``__init__`` sets it, the harness has to
|
||||
# mirror it or a Jellyfin-shaped player raises on the flattened surface
|
||||
player._media_info = None
|
||||
player.hass = entity_stublib.FakeHass()
|
||||
player._entity_tasks = set()
|
||||
player._channel_task = None
|
||||
@@ -461,7 +464,7 @@ class TestForegroundLadderTier:
|
||||
|
||||
assert player.state.value == "playing" # session tier, not the fg tier
|
||||
np = player._get_adb_now_playing()
|
||||
assert np["source"] == "media_session"
|
||||
assert np["via"] == "media_session"
|
||||
assert np["title"] == "Movie"
|
||||
|
||||
async def test_no_session_app_with_audio_hint(self, modules):
|
||||
@@ -478,7 +481,7 @@ class TestForegroundLadderTier:
|
||||
assert np == {
|
||||
"state": "idle",
|
||||
"title": "Media recently active",
|
||||
"source": "app",
|
||||
"via": "app",
|
||||
}
|
||||
|
||||
async def test_no_session_app_without_hint_returns_none(self, modules):
|
||||
@@ -498,7 +501,7 @@ class TestForegroundLadderTier:
|
||||
manager._last_known["ar.tvplayer.tv"] = (info, 0.0)
|
||||
|
||||
np = player._get_app_now_playing()
|
||||
assert np == {"state": "playing", "title": "Sky News", "source": "app"}
|
||||
assert np == {"state": "playing", "title": "Sky News", "via": "app"}
|
||||
|
||||
async def test_ttl_resurrect_guard_in_the_tier(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
@@ -513,13 +516,13 @@ class TestForegroundLadderTier:
|
||||
|
||||
# inside TTL → last-known title served
|
||||
np = player._get_app_now_playing()
|
||||
assert np == {"state": "playing", "title": "Sky News", "source": "app"}
|
||||
assert np == {"state": "playing", "title": "Sky News", "via": "app"}
|
||||
|
||||
# past STATE_STORE_TTL → 'playing' must NOT be resurrected;
|
||||
# the tier falls back to the last channel instead
|
||||
now = now + 61.0
|
||||
np = player._get_app_now_playing()
|
||||
assert np == {"state": "idle", "title": "101", "source": "app"}
|
||||
assert np == {"state": "idle", "title": "101", "via": "app"}
|
||||
|
||||
|
||||
class _MpState:
|
||||
@@ -827,7 +830,7 @@ class TestStateModelT55:
|
||||
manager._last_known["ar.tvplayer.tv"] = (info, 0.0)
|
||||
|
||||
np = player._get_app_now_playing()
|
||||
assert np == {"state": "buffering", "title": "Sky News", "source": "app"}
|
||||
assert np == {"state": "buffering", "title": "Sky News", "via": "app"}
|
||||
|
||||
|
||||
class TestChannelLeakGuard:
|
||||
@@ -849,7 +852,7 @@ class TestChannelLeakGuard:
|
||||
player._last_channel = "101"
|
||||
np = player._get_app_now_playing()
|
||||
|
||||
assert np == {"state": "idle", "title": "101", "source": "app"}
|
||||
assert np == {"state": "idle", "title": "101", "via": "app"}
|
||||
|
||||
async def test_audio_member_can_carry_the_channel_title(self, modules):
|
||||
player, manager = _ladder_player(
|
||||
@@ -1152,7 +1155,15 @@ class TestRecorderHygieneG12:
|
||||
player, _s, _i = make_player(modules)
|
||||
|
||||
declared = type(player)._unrecorded_attributes
|
||||
assert "media_dag_now_playing" in declared # dag internals (per-PAq)
|
||||
# t65: the nested ``media_dag_now_playing`` key is GONE; every flattened
|
||||
# name it was replaced by has to be declared, or the recorder stores a new
|
||||
# row per poll for each of them (G12)
|
||||
from nsh.media_player import now_playing_attribute_names
|
||||
|
||||
assert now_playing_attribute_names() <= declared
|
||||
assert "now_playing_via" in declared
|
||||
assert "now_playing_title" in declared
|
||||
assert "media_dag_now_playing" not in declared # the nested object is gone
|
||||
assert "media_dag_running_apps" in declared
|
||||
|
||||
def test_sensors_declare_poll_attributes(self, modules):
|
||||
@@ -2128,3 +2139,321 @@ class TestReportingFactsT58:
|
||||
|
||||
assert player.state.value == "off"
|
||||
|
||||
|
||||
# ──────────────────────── t65: the flattened now-playing surface ─────────────
|
||||
#
|
||||
# The user reads attributes as YAML: "These could do with being separate attributes
|
||||
# rather than an object in one?" The nested ``media_dag_now_playing`` object is
|
||||
# gone; every field it carried is a top-level ``now_playing_*`` attribute, the tier
|
||||
# indicator is ``now_playing_via`` (never ``source``, which on a media_player means
|
||||
# the selected app input), and no now-playing attribute is ever published as None.
|
||||
|
||||
|
||||
def _now_playing_attrs(player) -> dict:
|
||||
"""Just the now-playing surface of an entity payload (t65).
|
||||
|
||||
The nested pre-t65 key is included on purpose, so a regression that quietly
|
||||
brings the object back shows up in every state assertion here, not only in
|
||||
the one test that names it.
|
||||
"""
|
||||
return {
|
||||
key: value
|
||||
for key, value in player.extra_state_attributes.items()
|
||||
if key.startswith("now_playing_") or key == "media_dag_now_playing"
|
||||
}
|
||||
|
||||
|
||||
def make_now_playing_sensor(modules, jellyfin_state):
|
||||
"""The Jellyfin now-playing sensor over one canned Jellyfin entity state."""
|
||||
entry = SimpleNamespace(
|
||||
entry_id="e1",
|
||||
title="Shield",
|
||||
options={"jellyfin_entity": "media_player.jellyfin"},
|
||||
)
|
||||
hass = SimpleNamespace(
|
||||
states=SimpleNamespace(get=lambda entity_id: jellyfin_state)
|
||||
)
|
||||
return modules["sensor"].NvidiaShieldNowPlayingSensor(hass, entry)
|
||||
|
||||
|
||||
class TestNowPlayingFlattenedT65:
|
||||
"""t65: one reading -> separate top-level attributes, and never a None row."""
|
||||
|
||||
def test_a_jellyfin_reading_lands_in_its_own_attributes(self, modules):
|
||||
player, _m = _state_player(modules, jellyfin_state="playing")
|
||||
|
||||
attrs = player.extra_state_attributes
|
||||
|
||||
assert "media_dag_now_playing" not in attrs # the object is gone
|
||||
assert attrs["now_playing_via"] == "jellyfin"
|
||||
assert attrs["now_playing_state"] == "playing"
|
||||
assert attrs["now_playing_title"] == "Movie"
|
||||
# the DAG role is a different fact and keeps its own meaning
|
||||
assert attrs["media_dag_role"] == "source"
|
||||
|
||||
def test_the_tier_is_now_playing_via_and_source_stays_the_app_input(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
sessions=[
|
||||
MediaSessionInfo(
|
||||
package="com.netflix.ninja",
|
||||
title="Sky News",
|
||||
playing=True,
|
||||
artist="Anchor",
|
||||
)
|
||||
],
|
||||
foreground="com.netflix.ninja",
|
||||
)
|
||||
player._entry.options["app_name_map"] = {"com.netflix.ninja": "Netflix"}
|
||||
|
||||
attrs = player.extra_state_attributes
|
||||
|
||||
assert attrs["now_playing_via"] == "media_session"
|
||||
assert attrs["now_playing_title"] == "Sky News"
|
||||
assert attrs["now_playing_artist"] == "Anchor" # its own attribute now
|
||||
# `source`/`source_list` still mean the SELECTED APP INPUT
|
||||
assert player.source_list == ["Netflix"]
|
||||
assert attrs["media_dag_role"] == "source"
|
||||
|
||||
def test_the_flattener_drops_unknowns_and_keeps_real_zeros(self):
|
||||
from nsh.media_player import flatten_now_playing
|
||||
|
||||
assert flatten_now_playing(None) == {}
|
||||
assert flatten_now_playing({}) == {}
|
||||
assert flatten_now_playing(
|
||||
{
|
||||
"via": "app",
|
||||
"state": "idle",
|
||||
"title": None,
|
||||
"artist": "",
|
||||
"thumbnail": None,
|
||||
"position": 0,
|
||||
"season": 0,
|
||||
}
|
||||
) == {
|
||||
"now_playing_via": "app",
|
||||
"now_playing_state": "idle",
|
||||
"now_playing_position": 0,
|
||||
"now_playing_season": 0,
|
||||
}
|
||||
|
||||
def test_an_empty_reading_publishes_no_rows_at_all(self, modules):
|
||||
player, _m = _state_player(modules, jellyfin_state="playing")
|
||||
player._read = lambda entity_id: (
|
||||
_MpState("playing") if entity_id == "media_player.jellyfin" else None
|
||||
)
|
||||
|
||||
attrs = _now_playing_attrs(player)
|
||||
|
||||
# Jellyfin answered "playing" but has no metadata at all: the reading's
|
||||
# own facts survive and the unknowns are OMITTED, never nulled
|
||||
assert attrs == {
|
||||
"now_playing_via": "jellyfin",
|
||||
"now_playing_state": "playing",
|
||||
}
|
||||
|
||||
def test_no_now_playing_attribute_is_ever_null(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
players = [
|
||||
_state_player(modules, jellyfin_state="playing")[0],
|
||||
_state_player(
|
||||
modules,
|
||||
sessions=[MediaSessionInfo(package="com.netflix.ninja", playing=True)],
|
||||
foreground="com.netflix.ninja",
|
||||
)[0],
|
||||
_ladder_player(modules, foreground=TIVIMATE_PACKAGE)[0],
|
||||
]
|
||||
|
||||
for player in players:
|
||||
assert [k for k, v in _now_playing_attrs(player).items() if v is None] == []
|
||||
|
||||
def test_every_state_keeps_the_reading_and_the_state_in_step(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
cases = [
|
||||
({"jellyfin_state": "playing"}, "playing"),
|
||||
({"jellyfin_state": "paused"}, "paused"),
|
||||
(
|
||||
{
|
||||
"sessions": [
|
||||
MediaSessionInfo(
|
||||
package="com.netflix.ninja", buffering=True, title="Sky News"
|
||||
)
|
||||
],
|
||||
"foreground": "com.netflix.ninja",
|
||||
},
|
||||
"buffering",
|
||||
),
|
||||
({"power_entity_state": "off"}, "off"),
|
||||
]
|
||||
|
||||
for kwargs, expected in cases:
|
||||
player, _m = _state_player(modules, **kwargs)
|
||||
attrs = _now_playing_attrs(player)
|
||||
|
||||
assert player.state.value == expected, kwargs
|
||||
if expected == "off":
|
||||
# nothing is playing on a device that is off — and the power
|
||||
# entity's verdict outranks a stale session either way
|
||||
assert attrs == {}, kwargs
|
||||
continue
|
||||
assert attrs["now_playing_state"] == expected, kwargs
|
||||
|
||||
def test_off_publishes_no_rows_even_with_a_stale_jellyfin_session(self, modules):
|
||||
"""The gate that keeps a stale title off a device that is off."""
|
||||
player, _m = _state_player(
|
||||
modules, power_entity_state="off", jellyfin_state="playing"
|
||||
)
|
||||
|
||||
assert player._get_now_playing() is not None # the stale reading exists...
|
||||
assert player.state.value == "off" # ...and the device is off, so it is dropped
|
||||
assert _now_playing_attrs(player) == {}
|
||||
|
||||
def test_on_publishes_no_rows_because_nothing_reported(self, modules):
|
||||
player, _m = _state_player(modules)
|
||||
|
||||
assert player.state.value == "on"
|
||||
assert _now_playing_attrs(player) == {}
|
||||
|
||||
def test_an_idle_app_publishes_the_idle_row(self, modules):
|
||||
player, _m = _ladder_player(modules, foreground=TIVIMATE_PACKAGE)
|
||||
player._last_channel = "101"
|
||||
|
||||
assert player.state.value == "idle"
|
||||
assert _now_playing_attrs(player) == {
|
||||
"now_playing_via": "app",
|
||||
"now_playing_state": "idle",
|
||||
"now_playing_title": "101",
|
||||
}
|
||||
|
||||
def test_playing_to_idle_does_not_leave_the_title_behind(self, modules):
|
||||
"""The transition the user feels: the old title must not linger."""
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, manager = _state_player(
|
||||
modules,
|
||||
sessions=[
|
||||
MediaSessionInfo(
|
||||
package="com.netflix.ninja", playing=True, title="Sky News"
|
||||
)
|
||||
],
|
||||
foreground="com.netflix.ninja",
|
||||
)
|
||||
assert _now_playing_attrs(player)["now_playing_title"] == "Sky News"
|
||||
|
||||
# the session stops (the app is still open) and nothing is remembered
|
||||
manager.store.set("sessions", [])
|
||||
manager._last_known.clear()
|
||||
|
||||
attrs = _now_playing_attrs(player)
|
||||
|
||||
assert player.state.value == "idle"
|
||||
assert "Sky News" not in attrs.values()
|
||||
assert attrs == {}
|
||||
|
||||
def test_a_remembered_title_never_contradicts_the_entity_state(self, modules):
|
||||
"""t55's TTL bridge serves a last-known PLAYBACK state in the tier.
|
||||
|
||||
The published row must still agree with the entity: the ladder is the
|
||||
authority, so a dashboard reading `now_playing_state` never sees
|
||||
"playing" beside a state of idle.
|
||||
"""
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, manager = _ladder_player(modules, foreground="ar.tvplayer.tv")
|
||||
manager._clock = lambda: 0.0 # stamp 0.0, clock 0.0 → inside TTL
|
||||
info = MediaSessionInfo(
|
||||
package="ar.tvplayer.tv", playing=True, title="Sky News"
|
||||
)
|
||||
manager._last_known["ar.tvplayer.tv"] = (info, 0.0)
|
||||
|
||||
attrs = _now_playing_attrs(player)
|
||||
|
||||
assert player.state.value == "idle" # nothing LIVE is playing
|
||||
assert attrs["now_playing_state"] == "idle" # the row agrees with it
|
||||
assert attrs["now_playing_title"] == "Sky News" # the remembered identity
|
||||
assert attrs["now_playing_via"] == "app"
|
||||
|
||||
def test_privacy_mode_publishes_the_app_identity_not_the_media(self, modules):
|
||||
from nsh.ha_adb_common import MediaSessionInfo
|
||||
|
||||
player, _m = _state_player(
|
||||
modules,
|
||||
sessions=[
|
||||
MediaSessionInfo(
|
||||
package="com.netflix.ninja", playing=True, title="Sky News"
|
||||
)
|
||||
],
|
||||
foreground="com.netflix.ninja",
|
||||
)
|
||||
player._entry.options["app_name_map"] = {"com.netflix.ninja": "Netflix"}
|
||||
player._entry.options["adb_store_app_info"] = False # privacy mode
|
||||
|
||||
attrs = _now_playing_attrs(player)
|
||||
|
||||
assert attrs["now_playing_title"] == "Netflix" # the app, never the media
|
||||
assert attrs["now_playing_via"] == "media_session"
|
||||
assert "Sky News" not in attrs.values()
|
||||
assert player.state.value == "playing" # privacy does not hide the state
|
||||
|
||||
def test_the_identity_agrees_with_the_current_app_sensor(self, modules):
|
||||
player, manager = _state_player(modules, awake=True)
|
||||
player._entry.options["app_name_map"] = {"ar.tvplayer.tv": "TiviMate"}
|
||||
player._entry.runtime_data = manager
|
||||
manager.store.set("current_app", "ar.tvplayer.tv")
|
||||
sensor = make_app_sensor(modules, manager, player._entry)
|
||||
|
||||
attrs = player.extra_state_attributes
|
||||
|
||||
assert attrs["app_id"] == sensor.extra_state_attributes["app_id"]
|
||||
assert attrs["app_name"] == sensor.native_value == "TiviMate"
|
||||
|
||||
|
||||
class TestNowPlayingSensorRowsT65:
|
||||
"""t65: the Jellyfin now-playing sensor publishes no None rows either."""
|
||||
|
||||
def test_a_missing_media_type_is_omitted_not_nulled(self, modules):
|
||||
sensor = make_now_playing_sensor(
|
||||
modules, _MpState("playing", {"media_title": "Movie"})
|
||||
)
|
||||
|
||||
attrs = sensor.extra_state_attributes
|
||||
|
||||
assert attrs == {"playback_state": "playing", "title": "Movie"}
|
||||
assert "media_type" not in attrs
|
||||
|
||||
def test_a_zero_position_and_duration_are_real_readings(self, modules):
|
||||
sensor = make_now_playing_sensor(
|
||||
modules,
|
||||
_MpState(
|
||||
"paused",
|
||||
{
|
||||
"media_title": "Movie",
|
||||
"media_position": 0,
|
||||
"media_duration": 0,
|
||||
"media_season": 0,
|
||||
"media_episode": 0,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
attrs = sensor.extra_state_attributes
|
||||
|
||||
assert attrs["position"] == 0
|
||||
assert attrs["duration"] == 0
|
||||
assert attrs["season"] == 0
|
||||
assert attrs["episode"] == 0
|
||||
|
||||
def test_an_idle_entity_publishes_only_the_idle_row(self, modules):
|
||||
sensor = make_now_playing_sensor(modules, _MpState("idle"))
|
||||
|
||||
assert sensor.extra_state_attributes == {"playback_state": "idle"}
|
||||
|
||||
def test_a_missing_entity_is_still_idle(self, modules):
|
||||
sensor = make_now_playing_sensor(modules, None)
|
||||
|
||||
assert sensor.extra_state_attributes == {"playback_state": "idle"}
|
||||
|
||||
Reference in new issue
Block a user