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
@@ -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,
|
||||
ATTR_MEDIA_DAG_ROLE: "source",
|
||||
ATTR_MEDIA_DAG_NOW_PLAYING: now_playing}
|
||||
attrs = {
|
||||
"app_id": self.app_id,
|
||||
"app_name": self.app_name,
|
||||
ATTR_MEDIA_DAG_ROLE: "source",
|
||||
}
|
||||
# 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:
|
||||
|
||||
Reference in new issue
Block a user