Repository: postlund/pyatv

Issue:
Push updates are sent when nothing has changed
**Describe the bug**
This is a very old "bug" that I have somewhat let slip, but it annoys me too much so it's time to fix it. The gist is that push updates are supposed to be sent whenever something in the playing API changes, e.g. title or media type. But only a small portion of everything the device sends is used by pyatv, so it might happen that something unused triggers an update (mainly due to pyatv assuming that a device update should trigger a push update).

**To Reproduce**
Just use `atvremote ... push_updates`:

```
...
  Media type: Unknown
Device state: Playing
      Repeat: Off
     Shuffle: Off
--------------------
  Media type: Unknown
Device state: Idle
      Repeat: Off
     Shuffle: Off
--------------------
  Media type: Unknown
Device state: Idle
      Repeat: Off
     Shuffle: Off
--------------------
.
```

**Expected behavior**
Push updates should not be sent unless something changes.

**System Setup (please complete the following information):**
 - OS: any
 - Python: any
 - pyatv: any
- Apple TV: [e.g. Apple TV 4K tvOS 13.4]

**Additional context**
There are two ways to implement this as far as I see it. Since this is protocol dependent, each protocol (DMAP and MRP) can implement checks to only trigger updates if something of interest changes. The other way is to make it protocol independent by adding `__eq__` to `interface.Playing` and comparing previous playing information with the new information before dispatching it. As the second one is more generic, I would opt for that.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/postlund/pyatv/pull/920?src=pr&el=h1) Report
> :exclamation: No coverage uploaded for pull request base (`master@f9f7238`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference#section-missing-base-commit).
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/postlund/pyatv/pull/920/graphs/tree.svg?width=650&height=150&src=pr&token=wb6azJjRSY)](https://codecov.io/gh/postlund/pyatv/pull/920?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##             master     #920   +/-   ##
=========================================
  Coverage          ?   95.67%           
=========================================
  Files             ?       38           
  Lines             ?     3287           
  Branches          ?        0           
=========================================
  Hits              ?     3145           
  Misses            ?      142           
  Partials          ?        0           
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `95.67% <0.00%> (?)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags#carryforward-flags-in-the-pull-request-comment) to find out more.


------

[Continue to review full report at Codecov](https://codecov.io/gh/postlund/pyatv/pull/920?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/postlund/pyatv/pull/920?src=pr&el=footer). Last update [f9f7238...fd264a1](https://codecov.io/gh/postlund/pyatv/pull/920?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/docs/api/pyatv/interface.html b/docs/api/pyatv/interface.html
index 1d5bfa04..226387e9 100644
--- a/docs/api/pyatv/interface.html
+++ b/docs/api/pyatv/interface.html
@@ -155,6 +155,7 @@ link_group: api
 <h4><code><a title="pyatv.interface.PushUpdater" href="#pyatv.interface.PushUpdater">PushUpdater</a></code></h4>
 <ul class="">
 <li><code><a title="pyatv.interface.PushUpdater.active" href="#pyatv.interface.PushUpdater.active">active</a></code></li>
+<li><code><a title="pyatv.interface.PushUpdater.post_update" href="#pyatv.interface.PushUpdater.post_update">post_update</a></code></li>
 <li><code><a title="pyatv.interface.PushUpdater.start" href="#pyatv.interface.PushUpdater.start">start</a></code></li>
 <li><code><a title="pyatv.interface.PushUpdater.stop" href="#pyatv.interface.PushUpdater.stop">stop</a></code></li>
 </ul>
@@ -223,6 +224,7 @@ This module contains all the interfaces that represents a generic Apple TV devic
 all its features.
 &#34;&#34;&#34;
 
+import asyncio
 import re
 import inspect
 import hashlib
@@ -618,9 +620,23 @@ class RemoteControl(ABC):  # pylint: disable=too-many-public-methods
         raise exceptions.NotSupportedError()
 
 
+# TODO: Should be made into a dataclass when support for 3.6 is dropped
 class Playing(ABC):
     &#34;&#34;&#34;Base class for retrieving what is currently playing.&#34;&#34;&#34;
 
+    _PROPERTIES = [
+        &#34;media_type&#34;,
+        &#34;device_state&#34;,
+        &#34;title&#34;,
+        &#34;artist&#34;,
+        &#34;album&#34;,
+        &#34;genre&#34;,
+        &#34;total_time&#34;,
+        &#34;position&#34;,
+        &#34;shuffle&#34;,
+        &#34;repeat&#34;,
+    ]
+
     def __str__(self) -&gt; str:
         &#34;&#34;&#34;Convert this playing object to a readable string.&#34;&#34;&#34;
         output = []
@@ -664,6 +680,15 @@ class Playing(ABC):
 
         return &#34;\n&#34;.join(output)
 
+    def __eq__(self, other):
+        &#34;&#34;&#34;Compare if two objects are equal.&#34;&#34;&#34;
+        if isinstance(other, Playing):
+            for prop in self._PROPERTIES:
+                if getattr(self, prop) != getattr(other, prop):
+                    return False
+            return True
+        return False
+
     @property
     def hash(self) -&gt; str:
         &#34;&#34;&#34;Create a unique hash for what is currently playing.
@@ -680,69 +705,69 @@ class Playing(ABC):
     @abstractmethod
     def media_type(self) -&gt; const.MediaType:
         &#34;&#34;&#34;Type of media is currently playing, e.g. video, music.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property
     @abstractmethod
     def device_state(self) -&gt; const.DeviceState:
         &#34;&#34;&#34;Device state, e.g. playing or paused.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(22, &#34;Title&#34;, &#34;Title of playing media.&#34;)
     def title(self) -&gt; Optional[str]:
         &#34;&#34;&#34;Title of the current media, e.g. movie or song name.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(23, &#34;Artist&#34;, &#34;Artist of playing song.&#34;)
     def artist(self) -&gt; Optional[str]:
         &#34;&#34;&#34;Artist of the currently playing song.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(24, &#34;Album&#34;, &#34;Album from playing artist.&#34;)
     def album(self) -&gt; Optional[str]:
         &#34;&#34;&#34;Album of the currently playing song.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(25, &#34;Genre&#34;, &#34;Genre of playing song.&#34;)
     def genre(self) -&gt; Optional[str]:
         &#34;&#34;&#34;Genre of the currently playing song.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(26, &#34;TotalTime&#34;, &#34;Total length of playing media (seconds).&#34;)
     def total_time(self) -&gt; int:
         &#34;&#34;&#34;Total play time in seconds.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(27, &#34;Position&#34;, &#34;Current play time position.&#34;)
     def position(self) -&gt; int:
         &#34;&#34;&#34;Position in the playing media (seconds).&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(28, &#34;Shuffle&#34;, &#34;Shuffle stat.e&#34;)
     def shuffle(self) -&gt; const.ShuffleState:
         &#34;&#34;&#34;If shuffle is enabled or not.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(29, &#34;Repeat&#34;, &#34;Repeat state.&#34;)
     def repeat(self) -&gt; const.RepeatState:
         &#34;&#34;&#34;Repeat mode.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
 
 class App:
@@ -835,11 +860,16 @@ class PushUpdater(ABC, StateProducer):
     Listener interface: `pyatv.interface.PushListener`
     &#34;&#34;&#34;
 
+    def __init__(self, loop: asyncio.AbstractEventLoop):
+        &#34;&#34;&#34;Initialize a new PushUpdater.&#34;&#34;&#34;
+        self.loop = loop
+        self._previous_state: Optional[Playing] = None
+
     @property
     @abstractmethod
     def active(self) -&gt; bool:
         &#34;&#34;&#34;Return if push updater has been started.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @abstractmethod
     def start(self, initial_delay: int = 0) -&gt; None:
@@ -847,12 +877,19 @@ class PushUpdater(ABC, StateProducer):
 
         If an error occurs, start must be called again.
         &#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @abstractmethod
     def stop(self) -&gt; None:
         &#34;&#34;&#34;No longer forward updates to listener.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
+
+    def post_update(self, playing: Playing) -&gt; None:
+        &#34;&#34;&#34;Post an update to listener.&#34;&#34;&#34;
+        if playing != self._previous_state:
+            self.loop.call_soon(self.listener.playstatus_update, self, playing)
+
+        self._previous_state = playing
 
 
 class Stream(ABC):  # pylint: disable=too-few-public-methods
@@ -2292,6 +2329,19 @@ def pin(self, pin) -&gt; None:
 <pre><code class="python">class Playing(ABC):
     &#34;&#34;&#34;Base class for retrieving what is currently playing.&#34;&#34;&#34;
 
+    _PROPERTIES = [
+        &#34;media_type&#34;,
+        &#34;device_state&#34;,
+        &#34;title&#34;,
+        &#34;artist&#34;,
+        &#34;album&#34;,
+        &#34;genre&#34;,
+        &#34;total_time&#34;,
+        &#34;position&#34;,
+        &#34;shuffle&#34;,
+        &#34;repeat&#34;,
+    ]
+
     def __str__(self) -&gt; str:
         &#34;&#34;&#34;Convert this playing object to a readable string.&#34;&#34;&#34;
         output = []
@@ -2335,6 +2385,15 @@ def pin(self, pin) -&gt; None:
 
         return &#34;\n&#34;.join(output)
 
+    def __eq__(self, other):
+        &#34;&#34;&#34;Compare if two objects are equal.&#34;&#34;&#34;
+        if isinstance(other, Playing):
+            for prop in self._PROPERTIES:
+                if getattr(self, prop) != getattr(other, prop):
+                    return False
+            return True
+        return False
+
     @property
     def hash(self) -&gt; str:
         &#34;&#34;&#34;Create a unique hash for what is currently playing.
@@ -2351,69 +2410,69 @@ def pin(self, pin) -&gt; None:
     @abstractmethod
     def media_type(self) -&gt; const.MediaType:
         &#34;&#34;&#34;Type of media is currently playing, e.g. video, music.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property
     @abstractmethod
     def device_state(self) -&gt; const.DeviceState:
         &#34;&#34;&#34;Device state, e.g. playing or paused.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(22, &#34;Title&#34;, &#34;Title of playing media.&#34;)
     def title(self) -&gt; Optional[str]:
         &#34;&#34;&#34;Title of the current media, e.g. movie or song name.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(23, &#34;Artist&#34;, &#34;Artist of playing song.&#34;)
     def artist(self) -&gt; Optional[str]:
         &#34;&#34;&#34;Artist of the currently playing song.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(24, &#34;Album&#34;, &#34;Album from playing artist.&#34;)
     def album(self) -&gt; Optional[str]:
         &#34;&#34;&#34;Album of the currently playing song.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(25, &#34;Genre&#34;, &#34;Genre of playing song.&#34;)
     def genre(self) -&gt; Optional[str]:
         &#34;&#34;&#34;Genre of the currently playing song.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(26, &#34;TotalTime&#34;, &#34;Total length of playing media (seconds).&#34;)
     def total_time(self) -&gt; int:
         &#34;&#34;&#34;Total play time in seconds.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(27, &#34;Position&#34;, &#34;Current play time position.&#34;)
     def position(self) -&gt; int:
         &#34;&#34;&#34;Position in the playing media (seconds).&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(28, &#34;Shuffle&#34;, &#34;Shuffle stat.e&#34;)
     def shuffle(self) -&gt; const.ShuffleState:
         &#34;&#34;&#34;If shuffle is enabled or not.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(29, &#34;Repeat&#34;, &#34;Repeat state.&#34;)
     def repeat(self) -&gt; const.RepeatState:
         &#34;&#34;&#34;Repeat mode.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()</code></pre>
+        raise NotImplementedError</code></pre>
 </details>
 <h3>Ancestors</h3>
 <ul class="hlist">
@@ -2438,7 +2497,7 @@ def pin(self, pin) -&gt; None:
 @feature(24, &#34;Album&#34;, &#34;Album from playing artist.&#34;)
 def album(self) -&gt; Optional[str]:
     &#34;&#34;&#34;Album of the currently playing song.&#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.artist"><code class="name">var <span class="ident">artist</span> -> Optional[str]</code></dt>
@@ -2453,7 +2512,7 @@ def album(self) -&gt; Optional[str]:
 @feature(23, &#34;Artist&#34;, &#34;Artist of playing song.&#34;)
 def artist(self) -&gt; Optional[str]:
     &#34;&#34;&#34;Artist of the currently playing song.&#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.device_state"><code class="name">var <span class="ident">device_state</span> -> <a title="pyatv.const.DeviceState" href="const#pyatv.const.DeviceState">DeviceState</a></code></dt>
@@ -2467,7 +2526,7 @@ def artist(self) -&gt; Optional[str]:
 @abstractmethod
 def device_state(self) -&gt; const.DeviceState:
     &#34;&#34;&#34;Device state, e.g. playing or paused.&#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.genre"><code class="name">var <span class="ident">genre</span> -> Optional[str]</code></dt>
@@ -2482,7 +2541,7 @@ def device_state(self) -&gt; const.DeviceState:
 @feature(25, &#34;Genre&#34;, &#34;Genre of playing song.&#34;)
 def genre(self) -&gt; Optional[str]:
     &#34;&#34;&#34;Genre of the currently playing song.&#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.hash"><code class="name">var <span class="ident">hash</span> -> str</code></dt>
@@ -2518,7 +2577,7 @@ def hash(self) -&gt; str:
 @abstractmethod
 def media_type(self) -&gt; const.MediaType:
     &#34;&#34;&#34;Type of media is currently playing, e.g. video, music.&#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.position"><code class="name">var <span class="ident">position</span> -> int</code></dt>
@@ -2533,7 +2592,7 @@ def media_type(self) -&gt; const.MediaType:
 @feature(27, &#34;Position&#34;, &#34;Current play time position.&#34;)
 def position(self) -&gt; int:
     &#34;&#34;&#34;Position in the playing media (seconds).&#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.repeat"><code class="name">var <span class="ident">repeat</span> -> <a title="pyatv.const.RepeatState" href="const#pyatv.const.RepeatState">RepeatState</a></code></dt>
@@ -2548,7 +2607,7 @@ def position(self) -&gt; int:
 @feature(29, &#34;Repeat&#34;, &#34;Repeat state.&#34;)
 def repeat(self) -&gt; const.RepeatState:
     &#34;&#34;&#34;Repeat mode.&#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.shuffle"><code class="name">var <span class="ident">shuffle</span> -> <a title="pyatv.const.ShuffleState" href="const#pyatv.const.ShuffleState">ShuffleState</a></code></dt>
@@ -2563,7 +2622,7 @@ def repeat(self) -&gt; const.RepeatState:
 @feature(28, &#34;Shuffle&#34;, &#34;Shuffle stat.e&#34;)
 def shuffle(self) -&gt; const.ShuffleState:
     &#34;&#34;&#34;If shuffle is enabled or not.&#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.title"><code class="name">var <span class="ident">title</span> -> Optional[str]</code></dt>
@@ -2578,7 +2637,7 @@ def shuffle(self) -&gt; const.ShuffleState:
 @feature(22, &#34;Title&#34;, &#34;Title of playing media.&#34;)
 def title(self) -&gt; Optional[str]:
     &#34;&#34;&#34;Title of the current media, e.g. movie or song name.&#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.total_time"><code class="name">var <span class="ident">total_time</span> -> int</code></dt>
@@ -2593,7 +2652,7 @@ def title(self) -&gt; Optional[str]:
 @feature(26, &#34;TotalTime&#34;, &#34;Total length of playing media (seconds).&#34;)
 def total_time(self) -&gt; int:
     &#34;&#34;&#34;Total play time in seconds.&#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 </dl>
@@ -2817,11 +2876,12 @@ def playstatus_update(self, updater, playstatus: Playing) -&gt; None:
 </dd>
 <dt id="pyatv.interface.PushUpdater"><code class="flex name class">
 <span>class <span class="ident">PushUpdater</span></span>
+<span>(</span><span>loop: asyncio.events.AbstractEventLoop)</span>
 </code></dt>
 <dd>
 <section class="desc"><p>Base class for push/async updates from an Apple TV.</p>
 <p>Listener interface: <code><a title="pyatv.interface.PushListener" href="#pyatv.interface.PushListener">PushListener</a></code></p>
-<p>Initialize a new StateProducer instance.</p></section>
+<p>Initialize a new PushUpdater.</p></section>
 <details class="source">
 <summary>
 <span>Expand source code</span>
@@ -2832,11 +2892,16 @@ def playstatus_update(self, updater, playstatus: Playing) -&gt; None:
     Listener interface: `pyatv.interface.PushListener`
     &#34;&#34;&#34;
 
+    def __init__(self, loop: asyncio.AbstractEventLoop):
+        &#34;&#34;&#34;Initialize a new PushUpdater.&#34;&#34;&#34;
+        self.loop = loop
+        self._previous_state: Optional[Playing] = None
+
     @property
     @abstractmethod
     def active(self) -&gt; bool:
         &#34;&#34;&#34;Return if push updater has been started.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @abstractmethod
     def start(self, initial_delay: int = 0) -&gt; None:
@@ -2844,12 +2909,19 @@ def playstatus_update(self, updater, playstatus: Playing) -&gt; None:
 
         If an error occurs, start must be called again.
         &#34;&#34;&#34;
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @abstractmethod
     def stop(self) -&gt; None:
         &#34;&#34;&#34;No longer forward updates to listener.&#34;&#34;&#34;
-        raise exceptions.NotSupportedError()</code></pre>
+        raise NotImplementedError
+
+    def post_update(self, playing: Playing) -&gt; None:
+        &#34;&#34;&#34;Post an update to listener.&#34;&#34;&#34;
+        if playing != self._previous_state:
+            self.loop.call_soon(self.listener.playstatus_update, self, playing)
+
+        self._previous_state = playing</code></pre>
 </details>
 <h3>Ancestors</h3>
 <ul class="hlist">
@@ -2874,12 +2946,29 @@ def playstatus_update(self, updater, playstatus: Playing) -&gt; None:
 @abstractmethod
 def active(self) -&gt; bool:
     &#34;&#34;&#34;Return if push updater has been started.&#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 </dl>
 <h3>Methods</h3>
 <dl>
+<dt id="pyatv.interface.PushUpdater.post_update"><code class="name flex">
+<span>def <span class="ident">post_update</span></span>(<span>self, playing: <a title="pyatv.interface.Playing" href="#pyatv.interface.Playing">Playing</a>) -> NoneType</span>
+</code></dt>
+<dd>
+<section class="desc"><p>Post an update to listener.</p></section>
+<details class="source">
+<summary>
+<span>Expand source code</span>
+</summary>
+<pre><code class="python">def post_update(self, playing: Playing) -&gt; None:
+    &#34;&#34;&#34;Post an update to listener.&#34;&#34;&#34;
+    if playing != self._previous_state:
+        self.loop.call_soon(self.listener.playstatus_update, self, playing)
+
+    self._previous_state = playing</code></pre>
+</details>
+</dd>
 <dt id="pyatv.interface.PushUpdater.start"><code class="name flex">
 <span>def <span class="ident">start</span></span>(<span>self, initial_delay: int = 0) -> NoneType</span>
 </code></dt>
@@ -2896,7 +2985,7 @@ def start(self, initial_delay: int = 0) -&gt; None:
 
     If an error occurs, start must be called again.
     &#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.PushUpdater.stop"><code class="name flex">
@@ -2911,7 +3000,7 @@ def start(self, initial_delay: int = 0) -&gt; None:
 <pre><code class="python">@abstractmethod
 def stop(self) -&gt; None:
     &#34;&#34;&#34;No longer forward updates to listener.&#34;&#34;&#34;
-    raise exceptions.NotSupportedError()</code></pre>
+    raise NotImplementedError</code></pre>
 </details>
 </dd>
 </dl>
diff --git a/pyatv/dmap/__init__.py b/pyatv/dmap/__init__.py
index 42e5845c..6f3d3fe4 100644
--- a/pyatv/dmap/__init__.py
+++ b/pyatv/dmap/__init__.py
@@ -465,8 +465,7 @@ class DmapPushUpdater(PushUpdater):
 
     def __init__(self, loop, apple_tv, listener):
         """Initialize a new DmapPushUpdater instance."""
-        super().__init__()
-        self._loop = loop
+        super().__init__(loop)
         self._atv = apple_tv
         self._listener = weakref.ref(listener)
         self._future = None
@@ -494,7 +493,7 @@ class DmapPushUpdater(PushUpdater):
         # Delay before restarting after an error
         self._initial_delay = initial_delay
 
-        self._future = asyncio.ensure_future(self._poller(), loop=self._loop)
+        self._future = asyncio.ensure_future(self._poller(), loop=self.loop)
 
     def stop(self):
         """No longer forward updates to listener."""
@@ -509,14 +508,14 @@ class DmapPushUpdater(PushUpdater):
             # Sleep some time before waiting for updates
             if not first_call and self._initial_delay > 0:
                 _LOGGER.debug("Initial delay set to %d", self._initial_delay)
-                await asyncio.sleep(self._initial_delay, loop=self._loop)
+                await asyncio.sleep(self._initial_delay, loop=self.loop)
                 first_call = False
 
             try:
                 _LOGGER.debug("Waiting for playstatus updates")
                 playstatus = await self._atv.playstatus(use_revision=True, timeout=0)
 
-                self._loop.call_soon(self.listener.playstatus_update, self, playstatus)
+                self.post_update(playstatus)
             except asyncio.CancelledError:
                 break
 
@@ -524,7 +523,7 @@ class DmapPushUpdater(PushUpdater):
                 _LOGGER.exception("A communication error happened")
                 listener = self._listener()
                 if listener:
-                    self._loop.call_soon(listener.listener.connection_lost, ex)
+                    self.loop.call_soon(listener.listener.connection_lost, ex)
 
                 break
 
@@ -532,7 +531,7 @@ class DmapPushUpdater(PushUpdater):
             # exceptions to keep the API.
             except Exception as ex:  # pylint: disable=broad-except
                 _LOGGER.debug("Playstatus error occurred: %s", ex)
-                self._loop.call_soon(self.listener.playstatus_error, self, ex)
+                self.loop.call_soon(self.listener.playstatus_error, self, ex)
 
         self._future = None
 
diff --git a/pyatv/interface.py b/pyatv/interface.py
index 8ddc601d..31a02e81 100644
--- a/pyatv/interface.py
+++ b/pyatv/interface.py
@@ -4,6 +4,7 @@ This module contains all the interfaces that represents a generic Apple TV devic
 all its features.
 """
 
+import asyncio
 import re
 import inspect
 import hashlib
@@ -399,9 +400,23 @@ class RemoteControl(ABC):  # pylint: disable=too-many-public-methods
         raise exceptions.NotSupportedError()
 
 
+# TODO: Should be made into a dataclass when support for 3.6 is dropped
 class Playing(ABC):
     """Base class for retrieving what is currently playing."""
 
+    _PROPERTIES = [
+        "media_type",
+        "device_state",
+        "title",
+        "artist",
+        "album",
+        "genre",
+        "total_time",
+        "position",
+        "shuffle",
+        "repeat",
+    ]
+
     def __str__(self) -> str:
         """Convert this playing object to a readable string."""
         output = []
@@ -445,6 +460,15 @@ class Playing(ABC):
 
         return "\n".join(output)
 
+    def __eq__(self, other):
+        """Compare if two objects are equal."""
+        if isinstance(other, Playing):
+            for prop in self._PROPERTIES:
+                if getattr(self, prop) != getattr(other, prop):
+                    return False
+            return True
+        return False
+
     @property
     def hash(self) -> str:
         """Create a unique hash for what is currently playing.
@@ -461,69 +485,69 @@ class Playing(ABC):
     @abstractmethod
     def media_type(self) -> const.MediaType:
         """Type of media is currently playing, e.g. video, music."""
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property
     @abstractmethod
     def device_state(self) -> const.DeviceState:
         """Device state, e.g. playing or paused."""
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(22, "Title", "Title of playing media.")
     def title(self) -> Optional[str]:
         """Title of the current media, e.g. movie or song name."""
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(23, "Artist", "Artist of playing song.")
     def artist(self) -> Optional[str]:
         """Artist of the currently playing song."""
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(24, "Album", "Album from playing artist.")
     def album(self) -> Optional[str]:
         """Album of the currently playing song."""
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(25, "Genre", "Genre of playing song.")
     def genre(self) -> Optional[str]:
         """Genre of the currently playing song."""
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(26, "TotalTime", "Total length of playing media (seconds).")
     def total_time(self) -> int:
         """Total play time in seconds."""
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(27, "Position", "Current play time position.")
     def position(self) -> int:
         """Position in the playing media (seconds)."""
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(28, "Shuffle", "Shuffle stat.e")
     def shuffle(self) -> const.ShuffleState:
         """If shuffle is enabled or not."""
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @property  # type: ignore
     @abstractmethod
     @feature(29, "Repeat", "Repeat state.")
     def repeat(self) -> const.RepeatState:
         """Repeat mode."""
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
 
 class App:
@@ -616,11 +640,16 @@ class PushUpdater(ABC, StateProducer):
     Listener interface: `pyatv.interface.PushListener`
     """
 
+    def __init__(self, loop: asyncio.AbstractEventLoop):
+        """Initialize a new PushUpdater."""
+        self.loop = loop
+        self._previous_state: Optional[Playing] = None
+
     @property
     @abstractmethod
     def active(self) -> bool:
         """Return if push updater has been started."""
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @abstractmethod
     def start(self, initial_delay: int = 0) -> None:
@@ -628,12 +657,19 @@ class PushUpdater(ABC, StateProducer):
 
         If an error occurs, start must be called again.
         """
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
 
     @abstractmethod
     def stop(self) -> None:
         """No longer forward updates to listener."""
-        raise exceptions.NotSupportedError()
+        raise NotImplementedError
+
+    def post_update(self, playing: Playing) -> None:
+        """Post an update to listener."""
+        if playing != self._previous_state:
+            self.loop.call_soon(self.listener.playstatus_update, self, playing)
+
+        self._previous_state = playing
 
 
 class Stream(ABC):  # pylint: disable=too-few-public-methods
diff --git a/pyatv/mrp/__init__.py b/pyatv/mrp/__init__.py
index 3db8794d..e03f8790 100644
--- a/pyatv/mrp/__init__.py
+++ b/pyatv/mrp/__init__.py
@@ -560,8 +560,7 @@ class MrpPushUpdater(PushUpdater):
 
     def __init__(self, loop, metadata, psm):
         """Initialize a new MrpPushUpdater instance."""
-        super().__init__()
-        self.loop = loop
+        super().__init__(loop)
         self.metadata = metadata
         self.psm = psm
 
@@ -591,7 +590,7 @@ class MrpPushUpdater(PushUpdater):
         """State was updated for active player."""
         try:
             playstatus = await self.metadata.playing()
-            self.loop.call_soon(self.listener.playstatus_update, self, playstatus)
+            self.post_update(playstatus)
         except Exception as ex:  # pylint: disable=broad-except
             _LOGGER.debug("Playstatus error occurred: %s", ex)
             self.loop.call_soon(self.listener.playstatus_error, self, ex)


Test patch:
diff --git a/tests/test_interface.py b/tests/test_interface.py
index 5bcd0dfa..c8f80a39 100644
--- a/tests/test_interface.py
+++ b/tests/test_interface.py
@@ -1,7 +1,8 @@
 """Unit tests for pyatv.interface."""
+from unittest.mock import ANY, MagicMock
 
 import pytest
-from typing import Dict
+from typing import Dict, Optional
 
 from pyatv import convert, exceptions, interface
 from pyatv.interface import FeatureInfo
@@ -17,6 +18,22 @@ from pyatv.const import (
 )
 
 
+# Contains two valid values for each property that are tested
+# against each other
+eq_test_cases = [
+    ("media_type", MediaType.Video, MediaType.Music),
+    ("device_state", DeviceState.Idle, DeviceState.Playing),
+    ("title", "foo", "bar"),
+    ("artist", "abra", "kadabra"),
+    ("album", "banana", "apple"),
+    ("genre", "cat", "mouse"),
+    ("total_time", 210, 2000),
+    ("position", 555, 888),
+    ("shuffle", ShuffleState.Albums, ShuffleState.Songs),
+    ("repeat", RepeatState.Track, RepeatState.All),
+]
+
+
 class TestClass:
 
     variable = 1234
@@ -155,6 +172,23 @@ class FeaturesDummy(interface.Features):
         return FeatureInfo(state=state)
 
 
+class PushUpdaterDummy(interface.PushUpdater):
+    def active(self) -> bool:
+        """Return if push updater has been started."""
+        raise exceptions.NotSupportedError()
+
+    def start(self, initial_delay: int = 0) -> None:
+        """Begin to listen to updates.
+
+        If an error occurs, start must be called again.
+        """
+        raise exceptions.NotSupportedError()
+
+    def stop(self) -> None:
+        """No longer forward updates to listener."""
+        raise exceptions.NotSupportedError()
+
+
 @pytest.fixture
 def methods():
     yield interface.retrieve_commands(TestClass)
@@ -239,6 +273,26 @@ def test_playing_generate_same_hash():
     )
 
 
+def test_playing_eq_ensure_member_count():
+    # Fail if a property is added or removed to interface, just as a reminder to
+    # update equality comparison
+    assert len(PlayingDummy().__dict__) == 10
+
+
+@pytest.mark.parametrize(
+    "prop,value1,value2",
+    [pytest.param(*data, id=data[0]) for data in eq_test_cases],
+)
+def test_playing_field_equality(prop, value1, value2):
+    playing1 = PlayingDummy(**{prop: value1})
+    playing2 = PlayingDummy(**{prop: value2})
+    playing3 = PlayingDummy(**{prop: value2})
+
+    assert playing1 == playing1
+    assert playing1 != playing2
+    assert playing2 == playing3
+
+
 # METADATA
 
 
@@ -362,3 +416,23 @@ def test_app_properties():
 def test_app_str():
     app = interface.App("name", "id")
     assert "App: name (id)" == str(app)
+
+
+# PUSH UPDATER
+
+
+@pytest.mark.parametrize("updates", [1, 2, 3])
+def test_post_ignore_duplicate_update(event_loop, updates):
+    listener = MagicMock()
+    playing = PlayingDummy()
+
+    async def _post_updates(repeats: int):
+        updater = PushUpdaterDummy(event_loop)
+        updater.listener = listener
+        for _ in range(repeats):
+            updater.post_update(playing)
+
+    event_loop.run_until_complete(_post_updates(updates))
+
+    assert listener.playstatus_update.call_count == 1
+    listener.playstatus_update.assert_called_once_with(ANY, playing)