diff --git a/README.md b/README.md index 22fc6cc..137ef8a 100644 --- a/README.md +++ b/README.md @@ -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._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._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` diff --git a/custom_components/nvidia_shield/media_player.py b/custom_components/nvidia_shield/media_player.py index 2afbeed..dd57442 100644 --- a/custom_components/nvidia_shield/media_player.py +++ b/custom_components/nvidia_shield/media_player.py @@ -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: diff --git a/custom_components/nvidia_shield/sensor.py b/custom_components/nvidia_shield/sensor.py index f69ce70..bbb734b 100644 --- a/custom_components/nvidia_shield/sensor.py +++ b/custom_components/nvidia_shield/sensor.py @@ -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 diff --git a/tests/test_command_routing.py b/tests/test_command_routing.py index 44f65af..7abdfb4 100644 --- a/tests/test_command_routing.py +++ b/tests/test_command_routing.py @@ -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"} +