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
+542
-46
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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