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
+542 -46

No files matched your search

+58 -2
View File
@@ -634,11 +634,67 @@ exactly as before.
| Entity | Type | Description |
|--------|------|-------------|
| Media Player | `media_player` | Main entity with playback controls and metadata |
| Media Player | `media_player` | Main entity with playback controls, metadata and the flat `now_playing_*` attributes (below) |
| Current app (`sensor.current_app`) | `sensor` | Currently running app |
| Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string |
| Now playing (`sensor.now_playing`) | `sensor` | Formatted now-playing string, with its own flat attributes (below) |
| Pair ADB | `button` | Initiate or re-pair ADB connection |
### Now-playing attributes
**Since t65 the media player's now-playing reading is a set of separate
TOP-LEVEL attributes**, not one nested object — that object read badly in YAML:
| Attribute | What it holds |
| --- | --- |
| `now_playing_via` | which TIER produced the reading: `jellyfin`, `media_session` or `app` |
| `now_playing_state` | the reading's own state: `playing` / `paused` / `buffering` / `idle` |
| `now_playing_title` | the title — or, in privacy mode, the app identity |
| `now_playing_artist`, `now_playing_album` | the session's artist and album, when it has them |
| `now_playing_media_type` | the content type the source reported |
| `now_playing_series`, `now_playing_season`, `now_playing_episode` | series metadata, when the source reports it |
| `now_playing_duration`, `now_playing_position`, `now_playing_position_updated_at` | playback timing |
| `now_playing_thumbnail` | the artwork URL (or the Jellyfin entity picture) |
| `now_playing_media_info` | the Jellyfin media-info dict, when that entity has one |
The rules that keep the set predictable:
- **Only what the tier answered is published.** An unknown field is OMITTED, never
`null`, so nothing ever reads as `now_playing_title: null`.
- **A device that reads `off` publishes none of them.** A Jellyfin session that has
gone stale must not put a title beside a device that is off, and the state
already says nothing is playing. `on` (no source reported a power or playback
fact) publishes none either — there is no reading to publish.
- **`now_playing_via` is deliberately not `source`.** On a media player, `source`
and `source_list` mean the selected app input (`Netflix`, `TiviMate`, …) and they
still do; naming the tier `source` is exactly what made the reading look like
media metadata from somewhere else. `media_dag_role` keeps its own separate
meaning (that this entity is the "source" role of the media graph).
- **`now_playing_state` IS the entity's own state**, so a dashboard never reads two
different answers: the state ladder is the authority, and the foreground tier's
last-known-playback bridge can still publish a title while the state is already
`idle`.
**Compatibility: `media_dag_now_playing` is GONE** — not deprecated, removed. It
was one object; its contents are the attributes above. No alias is kept: a nested
copy would double every poll's payload, and these attributes are declared
*unrecorded* for exactly that reason (they must not bloat the recorder database). A
template that read the object needs one rename:
```jinja
{# before #}
{{ state_attr('media_player.shield', 'media_dag_now_playing').title }}
{# after #}
{{ state_attr('media_player.shield', 'now_playing_title') }}
```
The Jellyfin-backed `sensor.<entry>_now_playing` keeps its own flat set
(`playback_state`, `title`, `media_type`, `series`, `season`, `episode`,
`duration`, `position`, `thumbnail`), and it follows the same rule: a field the
Jellyfin entity did not answer is omitted, so there are no `title: null` rows
there either. Its `playback_state` reads `idle` whenever that session is not
playing. `sensor.<entry>_current_app` publishes `app_id`/`app_name`, the same
identity the media player carries beside its now-playing attributes.
## Development
- **Repository:** [Gitea — git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield](https://git.notexpectedyet.com/notexpectedyet/ha-nvidia-shield), branch `master`
+128 -18
View File
@@ -8,7 +8,10 @@ this entity is a thin callback-view over it.
State architecture:
- Power ladder: protocol is_on push → ADB wake (exact mWakefulness) → plug off
- Playback state: Jellyfin (polling, slow 10-30s) or ADB media session
- Metadata: Jellyfin attributes
- Metadata: Jellyfin attributes, published as separate top-level
``now_playing_*`` attributes — t65 flattened the one nested
``media_dag_now_playing`` object into them, and ``now_playing_via`` names the
tier that produced the reading (``jellyfin`` / ``media_session`` / ``app``)
- Commands: a configured per-command script (t44) → protocol key push → ADB
keyevent. The external androidtv_remote entity tier that used to back this is
gone — t41 removed it with the entity — so a command has exactly one owner.
@@ -88,7 +91,6 @@ from homeassistant.helpers.event import async_call_later, async_track_state_chan
from .connection import ShieldConnectionManager
from .const import (
ATTR_MEDIA_DAG_JELLYFIN_ENTITY,
ATTR_MEDIA_DAG_NOW_PLAYING,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
ATTR_MEDIA_DAG_ROLE,
@@ -157,6 +159,80 @@ BROWSE_ROOT_ID = "root"
#: exactly what ``can_expand=False`` and ``children=[]`` say.
BROWSE_ROOT_TYPE = str(MediaClass.DIRECTORY)
#: t65: the now-playing surface is FLATTENED into separate top-level attributes.
#:
#: 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, and every field it carried is published on its own, prefixed so it stays
#: readable beside the entity's other attributes:
#:
#: now_playing_via: media_session
#: now_playing_state: playing
#: now_playing_title: Sky News
#: now_playing_artist: ...
#:
#: Two rules hold the surface together:
#:
#: * **No None.** HA renders a ``None`` attribute as ``attribute: null`` in the
#: template editor, so an unknown field is OMITTED rather than published — a
#: reading carries only what the tier actually answered.
#: * ``now_playing_via`` names the TIER that produced the reading (``jellyfin``,
#: ``media_session`` or ``app``). It is deliberately NOT ``source``:
#: ``source``/``source_list`` already mean "the selected app input" on a
#: media_player, and a tier key called ``source`` is what made the reading look
#: like media metadata from somewhere else.
NOW_PLAYING_PREFIX = "now_playing_"
#: The tier indicator's attribute name.
ATTR_NOW_PLAYING_VIA = f"{NOW_PLAYING_PREFIX}via"
#: Every field the three tiers can put in a reading (t55/t65). A reading's own key
#: becomes ``NOW_PLAYING_PREFIX + key`` on the entity, except ``via``.
NOW_PLAYING_READING_KEYS = (
"via",
"state",
"title",
"media_type",
"artist",
"album",
"series",
"season",
"episode",
"duration",
"position",
"position_updated_at",
"thumbnail",
"media_info",
)
def now_playing_attribute_names() -> frozenset[str]:
"""The flat now-playing attribute names — the whole published surface (t65)."""
return frozenset(
ATTR_NOW_PLAYING_VIA if key == "via" else f"{NOW_PLAYING_PREFIX}{key}"
for key in NOW_PLAYING_READING_KEYS
)
def flatten_now_playing(now_playing: dict | None) -> dict:
"""One tier reading -> separate TOP-LEVEL attributes (t65).
Unknown fields are dropped, never published as ``None``: the complaint is
readability in YAML and templates, and a ``now_playing_title: null`` row is
exactly the noise this removes. Empty strings go the same way; ``0`` and
``False`` are real values and stay.
"""
if not now_playing:
return {}
flat: dict = {}
for key, value in now_playing.items():
if value is None or value == "":
continue
name = ATTR_NOW_PLAYING_VIA if key == "via" else f"{NOW_PLAYING_PREFIX}{key}"
flat[name] = value
return flat
_BASE_FEATURES = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
@@ -1024,8 +1100,12 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
if not jf:
return None
attrs = jf.attributes
np = {"state": self.state.value, "title": attrs.get("media_title"),
"media_type": attrs.get("media_content_type")}
np = {
"state": self.state.value,
"title": attrs.get("media_title"),
"media_type": attrs.get("media_content_type"),
"via": "jellyfin",
}
for src, dst in [("media_series_title", "series"), ("media_season", "season"),
("media_episode", "episode"), ("media_duration", "duration"),
("media_position", "position"),
@@ -1039,6 +1119,22 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
np["media_info"] = self._media_info
return np
def _now_playing(self) -> dict | None:
"""The first tier with something to say (t65).
Order is the ladder's own confidence order (t55): Jellyfin, then the live
ADB media session, then the foreground app. The tier that answers is the
one named in ``now_playing_via``.
"""
for reading in (
self._get_now_playing(),
self._get_adb_now_playing(),
self._get_app_now_playing(),
):
if reading:
return reading
return None
def _session_playback_label(self, info) -> str:
"""``playing``/``buffering``/``paused``/``idle`` for a live session (t55).
@@ -1065,7 +1161,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
return {
"state": self._session_playback_label(info),
"title": self.app_name or self.app_id or "Playing",
"source": "media_session",
"via": "media_session",
}
return None
@@ -1074,7 +1170,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
np = {
"state": self._session_playback_label(info),
"title": info.title,
"source": "media_session",
"via": "media_session",
}
if info.artist:
np["artist"] = info.artist
@@ -1122,7 +1218,7 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# Bare foreground app: no now-playing identity, but the device is
# ON — the entity ladder reports IDLE for this (t55).
return None
return {"state": state, "title": title, "source": "app"}
return {"state": state, "title": title, "via": "app"}
def _get_power_data(self) -> dict | None:
"""Read current values from discovered power sensors."""
@@ -1158,9 +1254,13 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
# G12 (t30): attributes that change with every poll must not bloat the
# database (entity doc) — dag internals are declared unrecorded.
# t65: the flattened now-playing surface replaces the single nested
# ``media_dag_now_playing`` key, so EVERY name it can publish is declared here
# (``now_playing_attribute_names()``) — otherwise the recorder starts storing a
# fresh row per poll for each of them.
_unrecorded_attributes = frozenset(
{
ATTR_MEDIA_DAG_NOW_PLAYING,
*now_playing_attribute_names(),
ATTR_MEDIA_DAG_RUNNING_APPS,
ATTR_MEDIA_DAG_RECEIVER_ENTITY,
ATTR_MEDIA_DAG_RECEIVER_INPUT,
@@ -1178,17 +1278,27 @@ class NvidiaShieldMediaPlayer(MediaPlayerEntity):
``TypeError: 'method' object is not iterable``, so the entity could
never render on a live install.
"""
# Now playing: prefer Jellyfin, fall back to ADB media session,
# then the foreground tier (P3-3a).
now_playing = self._get_now_playing()
if not now_playing:
now_playing = self._get_adb_now_playing()
if not now_playing:
now_playing = self._get_app_now_playing()
attrs = {"app_id": self.app_id, "app_name": self.app_name,
attrs = {
"app_id": self.app_id,
"app_name": self.app_name,
ATTR_MEDIA_DAG_ROLE: "source",
ATTR_MEDIA_DAG_NOW_PLAYING: now_playing}
}
# t65: the reading is FLATTENED into separate top-level attributes.
# OFF is excluded deliberately: a Jellyfin session that has gone stale
# must not publish a title beside a device that is off ("no stale title
# from a previous app lingering"), and the state already says nothing is
# playing. Every other state publishes whatever the tier answered, and
# nothing it did not (no None rows — see ``flatten_now_playing``).
if self.state != MediaPlayerState.OFF:
state = self.state.value
reading = self._now_playing()
if reading:
# The published state is the ENTITY's own state, so a dashboard
# never reads two answers: the tier can briefly serve a
# last-known playback state (t55's TTL bridge) or sit inside an
# intent window, and the state ladder is the authority in both.
reading = {**reading, "state": state}
attrs.update(flatten_now_playing(reading))
if self._receiver_entity:
attrs[ATTR_MEDIA_DAG_RECEIVER_ENTITY] = self._receiver_entity
if self._receiver_input:
+19 -18
View File
@@ -299,23 +299,24 @@ class NvidiaShieldNowPlayingSensor(NvidiaShieldSensorBase):
return {"playback_state": "idle"}
attrs = state.attributes
result = {
"playback_state": state.state,
"title": attrs.get("media_title"),
"media_type": attrs.get("media_content_type"),
}
if attrs.get("media_series_title"):
result["series"] = attrs["media_series_title"]
if attrs.get("media_season") is not None:
result["season"] = attrs["media_season"]
if attrs.get("media_episode") is not None:
result["episode"] = attrs["media_episode"]
if attrs.get("media_duration") is not None:
result["duration"] = attrs["media_duration"]
if attrs.get("media_position") is not None:
result["position"] = attrs["media_position"]
if attrs.get("entity_picture"):
result["thumbnail"] = attrs["entity_picture"]
# t65: the same "no None rows" rule the media player's flattened surface
# follows — an attribute that reads `title: null` in the template editor
# is noise, so a field the Jellyfin entity did not answer is OMITTED
# rather than published. Zero stays: 0 s of position is a real reading.
result = {"playback_state": state.state}
for key, source in (
("title", "media_title"),
("media_type", "media_content_type"),
("series", "media_series_title"),
("season", "media_season"),
("episode", "media_episode"),
("duration", "media_duration"),
("position", "media_position"),
("thumbnail", "entity_picture"),
):
value = attrs.get(source)
if value is None or value == "":
continue
result[key] = value
return result
+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"}