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:
James Mackay committed 2026-10-12 10:13:27 +01:00
1 parent 46738063aa
commit a1ce28996e
4 files changed
+543 -47

No files matched your search

+337 -8
View File
@@ -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"}