Repository: postlund/pyatv

Issue:
Playing objects returned by playing interface is not immutable for MRP
**Describe the bug**
A while back I made some changes so that `Playing` objects from `Medata.playing` are immutable. Before that, content would potentially change when new updates were made because the returned object would query the current player state. So the change involved making a copy of the internal state for each playing object. At first this seemed fine, but I have found a corner case...

The player state manager (`pyatv.mrp.player_state.PlayerStateManager`) stores content from some of the protobuf messages which are then queried to get various metadata. One such example is parts of the `SetStateMessage`, like the play queue. These messages are copied using `deepcopy`, which at first seems good. The problem is that is does not seem to properly copy sub-messages. So when a message contains a message of another type (I.e. not basic primitives like strings and integers), a reference is saved to that message instead of a new copy. As this is particularly a case that happens here, problems arise when certain metadata like position, title or duration changes: it will propagate to all `Playing` instances currently alive. I have verified this and proven it with a functional test.

**To Reproduce**
I basically saved a copy of a `Playing` object, changed something related to metadata and observed that the instance I had updated without having to get a new instance.

**Expected behavior**
A `Playing` object should be immutable and never change.

**System Setup (please complete the following information):**
 - OS: any
 - Python: any
 - pyatv: any
- Apple TV: any with tvOS

**Additional context**
Original work in #565.

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/postlund/pyatv/pull/943?src=pr&el=h1) Report
> Merging [#943](https://codecov.io/gh/postlund/pyatv/pull/943?src=pr&el=desc) (852b5f3) into [master](https://codecov.io/gh/postlund/pyatv/commit/f0f7eaf64c824d40ba72ef727ffb37ea87979f25?el=desc) (f0f7eaf) will **increase** coverage by `0.02%`.
> The diff coverage is `96.59%`.

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

```diff
@@            Coverage Diff             @@
##           master     #943      +/-   ##
==========================================
+ Coverage   95.71%   95.74%   +0.02%     
==========================================
  Files          40       38       -2     
  Lines        3755     3382     -373     
==========================================
- Hits         3594     3238     -356     
+ Misses        161      144      -17     
```

| Flag | Coverage Δ | |
|---|---|---|
| unittests | `95.74% <96.59%> (?)` | |

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.

| [Impacted Files](https://codecov.io/gh/postlund/pyatv/pull/943?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [pyatv/mrp/protocol.py](https://codecov.io/gh/postlund/pyatv/pull/943/diff?src=pr&el=tree#diff-cHlhdHYvbXJwL3Byb3RvY29sLnB5) | `91.37% <84.00%> (-2.10%)` | :arrow_down: |
| [pyatv/dmap/\_\_init\_\_.py](https://codecov.io/gh/postlund/pyatv/pull/943/diff?src=pr&el=tree#diff-cHlhdHYvZG1hcC9fX2luaXRfXy5weQ==) | `97.70% <97.95%> (+0.57%)` | :arrow_up: |
| [pyatv/support/dns.py](https://codecov.io/gh/postlund/pyatv/pull/943/diff?src=pr&el=tree#diff-cHlhdHYvc3VwcG9ydC9kbnMucHk=) | `95.39% <98.03%> (+1.57%)` | :arrow_up: |
| [pyatv/dmap/daap.py](https://codecov.io/gh/postlund/pyatv/pull/943/diff?src=pr&el=tree#diff-cHlhdHYvZG1hcC9kYWFwLnB5) | `97.19% <100.00%> (ø)` | |
| [pyatv/interface.py](https://codecov.io/gh/postlund/pyatv/pull/943/diff?src=pr&el=tree#diff-cHlhdHYvaW50ZXJmYWNlLnB5) | `99.28% <100.00%> (+0.02%)` | :arrow_up: |
| [pyatv/mrp/connection.py](https://codecov.io/gh/postlund/pyatv/pull/943/diff?src=pr&el=tree#diff-cHlhdHYvbXJwL2Nvbm5lY3Rpb24ucHk=) | `82.10% <100.00%> (ø)` | |
| [pyatv/mrp/player\_state.py](https://codecov.io/gh/postlund/pyatv/pull/943/diff?src=pr&el=tree#diff-cHlhdHYvbXJwL3BsYXllcl9zdGF0ZS5weQ==) | `98.95% <100.00%> (+0.02%)` | :arrow_up: |
| [pyatv/support/mdns.py](https://codecov.io/gh/postlund/pyatv/pull/943/diff?src=pr&el=tree#diff-cHlhdHYvc3VwcG9ydC9tZG5zLnB5) | `87.94% <100.00%> (+0.05%)` | :arrow_up: |
| [pyatv/airplay/server.py](https://codecov.io/gh/postlund/pyatv/pull/943/diff?src=pr&el=tree#diff-cHlhdHYvYWlycGxheS9zZXJ2ZXIucHk=) | `90.32% <0.00%> (-0.59%)` | :arrow_down: |
| ... and [11 more](https://codecov.io/gh/postlund/pyatv/pull/943/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/postlund/pyatv/pull/943?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/943?src=pr&el=footer). Last update [2b8fb74...b187b4c](https://codecov.io/gh/postlund/pyatv/pull/943?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 226387e9..2e44eaf1 100644
--- a/docs/api/pyatv/interface.html
+++ b/docs/api/pyatv/interface.html
@@ -635,8 +635,36 @@ class Playing(ABC):
         &#34;position&#34;,
         &#34;shuffle&#34;,
         &#34;repeat&#34;,
+        &#34;hash&#34;,
     ]
 
+    def __init__(
+        self,
+        media_type: const.MediaType = const.MediaType.Unknown,
+        device_state: const.DeviceState = const.DeviceState.Idle,
+        title: Optional[str] = None,
+        artist: Optional[str] = None,
+        album: Optional[str] = None,
+        genre: Optional[str] = None,
+        total_time: Optional[int] = None,
+        position: Optional[int] = None,
+        shuffle: Optional[const.ShuffleState] = None,
+        repeat: Optional[const.RepeatState] = None,
+        hash: Optional[str] = None,
+    ) -&gt; None:
+        &#34;&#34;&#34;Initialize a new Playing instance.&#34;&#34;&#34;
+        self._media_type = media_type
+        self._device_state = device_state
+        self._title = title
+        self._artist = artist
+        self._album = album
+        self._genre = genre
+        self._total_time = total_time
+        self._position = position
+        self._shuffle = shuffle
+        self._repeat = repeat
+        self._hash = hash
+
     def __str__(self) -&gt; str:
         &#34;&#34;&#34;Convert this playing object to a readable string.&#34;&#34;&#34;
         output = []
@@ -696,78 +724,71 @@ class Playing(ABC):
         The hash is based on title, artist, album and total time. It should
         always be the same for the same content, but it is not guaranteed.
         &#34;&#34;&#34;
+        if self._hash:
+            return self._hash
+
         base = &#34;{0}{1}{2}{3}&#34;.format(
             self.title, self.artist, self.album, self.total_time
         )
         return hashlib.sha256(base.encode(&#34;utf-8&#34;)).hexdigest()
 
     @property
-    @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 NotImplementedError
+        return self._media_type
 
     @property
-    @abstractmethod
     def device_state(self) -&gt; const.DeviceState:
         &#34;&#34;&#34;Device state, e.g. playing or paused.&#34;&#34;&#34;
-        raise NotImplementedError
+        return self._device_state
 
     @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 NotImplementedError
+        return self._title
 
     @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 NotImplementedError
+        return self._artist
 
     @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 NotImplementedError
+        return self._album
 
     @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 NotImplementedError
+        return self._genre
 
     @property  # type: ignore
-    @abstractmethod
     @feature(26, &#34;TotalTime&#34;, &#34;Total length of playing media (seconds).&#34;)
-    def total_time(self) -&gt; int:
+    def total_time(self) -&gt; Optional[int]:
         &#34;&#34;&#34;Total play time in seconds.&#34;&#34;&#34;
-        raise NotImplementedError
+        return self._total_time
 
     @property  # type: ignore
-    @abstractmethod
     @feature(27, &#34;Position&#34;, &#34;Current play time position.&#34;)
-    def position(self) -&gt; int:
+    def position(self) -&gt; Optional[int]:
         &#34;&#34;&#34;Position in the playing media (seconds).&#34;&#34;&#34;
-        raise NotImplementedError
+        return self._position
 
     @property  # type: ignore
-    @abstractmethod
-    @feature(28, &#34;Shuffle&#34;, &#34;Shuffle stat.e&#34;)
-    def shuffle(self) -&gt; const.ShuffleState:
+    @feature(28, &#34;Shuffle&#34;, &#34;Shuffle state.&#34;)
+    def shuffle(self) -&gt; Optional[const.ShuffleState]:
         &#34;&#34;&#34;If shuffle is enabled or not.&#34;&#34;&#34;
-        raise NotImplementedError
+        return self._shuffle
 
     @property  # type: ignore
-    @abstractmethod
     @feature(29, &#34;Repeat&#34;, &#34;Repeat state.&#34;)
-    def repeat(self) -&gt; const.RepeatState:
+    def repeat(self) -&gt; Optional[const.RepeatState]:
         &#34;&#34;&#34;Repeat mode.&#34;&#34;&#34;
-        raise NotImplementedError
+        return self._repeat
 
 
 class App:
@@ -2319,9 +2340,11 @@ def pin(self, pin) -&gt; None:
 </dd>
 <dt id="pyatv.interface.Playing"><code class="flex name class">
 <span>class <span class="ident">Playing</span></span>
+<span>(</span><span>media_type: <a title="pyatv.const.MediaType" href="const#pyatv.const.MediaType">MediaType</a> = MediaType.Unknown, device_state: <a title="pyatv.const.DeviceState" href="const#pyatv.const.DeviceState">DeviceState</a> = DeviceState.Idle, title: Optional[str] = None, artist: Optional[str] = None, album: Optional[str] = None, genre: Optional[str] = None, total_time: Optional[int] = None, position: Optional[int] = None, shuffle: Optional[<a title="pyatv.const.ShuffleState" href="const#pyatv.const.ShuffleState">ShuffleState</a>] = None, repeat: Optional[<a title="pyatv.const.RepeatState" href="const#pyatv.const.RepeatState">RepeatState</a>] = None, hash: Optional[str] = None)</span>
 </code></dt>
 <dd>
-<section class="desc"><p>Base class for retrieving what is currently playing.</p></section>
+<section class="desc"><p>Base class for retrieving what is currently playing.</p>
+<p>Initialize a new Playing instance.</p></section>
 <details class="source">
 <summary>
 <span>Expand source code</span>
@@ -2340,8 +2363,36 @@ def pin(self, pin) -&gt; None:
         &#34;position&#34;,
         &#34;shuffle&#34;,
         &#34;repeat&#34;,
+        &#34;hash&#34;,
     ]
 
+    def __init__(
+        self,
+        media_type: const.MediaType = const.MediaType.Unknown,
+        device_state: const.DeviceState = const.DeviceState.Idle,
+        title: Optional[str] = None,
+        artist: Optional[str] = None,
+        album: Optional[str] = None,
+        genre: Optional[str] = None,
+        total_time: Optional[int] = None,
+        position: Optional[int] = None,
+        shuffle: Optional[const.ShuffleState] = None,
+        repeat: Optional[const.RepeatState] = None,
+        hash: Optional[str] = None,
+    ) -&gt; None:
+        &#34;&#34;&#34;Initialize a new Playing instance.&#34;&#34;&#34;
+        self._media_type = media_type
+        self._device_state = device_state
+        self._title = title
+        self._artist = artist
+        self._album = album
+        self._genre = genre
+        self._total_time = total_time
+        self._position = position
+        self._shuffle = shuffle
+        self._repeat = repeat
+        self._hash = hash
+
     def __str__(self) -&gt; str:
         &#34;&#34;&#34;Convert this playing object to a readable string.&#34;&#34;&#34;
         output = []
@@ -2401,88 +2452,76 @@ def pin(self, pin) -&gt; None:
         The hash is based on title, artist, album and total time. It should
         always be the same for the same content, but it is not guaranteed.
         &#34;&#34;&#34;
+        if self._hash:
+            return self._hash
+
         base = &#34;{0}{1}{2}{3}&#34;.format(
             self.title, self.artist, self.album, self.total_time
         )
         return hashlib.sha256(base.encode(&#34;utf-8&#34;)).hexdigest()
 
     @property
-    @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 NotImplementedError
+        return self._media_type
 
     @property
-    @abstractmethod
     def device_state(self) -&gt; const.DeviceState:
         &#34;&#34;&#34;Device state, e.g. playing or paused.&#34;&#34;&#34;
-        raise NotImplementedError
+        return self._device_state
 
     @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 NotImplementedError
+        return self._title
 
     @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 NotImplementedError
+        return self._artist
 
     @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 NotImplementedError
+        return self._album
 
     @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 NotImplementedError
+        return self._genre
 
     @property  # type: ignore
-    @abstractmethod
     @feature(26, &#34;TotalTime&#34;, &#34;Total length of playing media (seconds).&#34;)
-    def total_time(self) -&gt; int:
+    def total_time(self) -&gt; Optional[int]:
         &#34;&#34;&#34;Total play time in seconds.&#34;&#34;&#34;
-        raise NotImplementedError
+        return self._total_time
 
     @property  # type: ignore
-    @abstractmethod
     @feature(27, &#34;Position&#34;, &#34;Current play time position.&#34;)
-    def position(self) -&gt; int:
+    def position(self) -&gt; Optional[int]:
         &#34;&#34;&#34;Position in the playing media (seconds).&#34;&#34;&#34;
-        raise NotImplementedError
+        return self._position
 
     @property  # type: ignore
-    @abstractmethod
-    @feature(28, &#34;Shuffle&#34;, &#34;Shuffle stat.e&#34;)
-    def shuffle(self) -&gt; const.ShuffleState:
+    @feature(28, &#34;Shuffle&#34;, &#34;Shuffle state.&#34;)
+    def shuffle(self) -&gt; Optional[const.ShuffleState]:
         &#34;&#34;&#34;If shuffle is enabled or not.&#34;&#34;&#34;
-        raise NotImplementedError
+        return self._shuffle
 
     @property  # type: ignore
-    @abstractmethod
     @feature(29, &#34;Repeat&#34;, &#34;Repeat state.&#34;)
-    def repeat(self) -&gt; const.RepeatState:
+    def repeat(self) -&gt; Optional[const.RepeatState]:
         &#34;&#34;&#34;Repeat mode.&#34;&#34;&#34;
-        raise NotImplementedError</code></pre>
+        return self._repeat</code></pre>
 </details>
 <h3>Ancestors</h3>
 <ul class="hlist">
 <li>abc.ABC</li>
 </ul>
-<h3>Subclasses</h3>
-<ul class="hlist">
-<li>pyatv.dmap.DmapPlaying</li>
-<li>pyatv.mrp.MrpPlaying</li>
-</ul>
 <h3>Instance variables</h3>
 <dl>
 <dt id="pyatv.interface.Playing.album"><code class="name">var <span class="ident">album</span> -> Optional[str]</code></dt>
@@ -2493,11 +2532,10 @@ def pin(self, pin) -&gt; None:
 <span>Expand source code</span>
 </summary>
 <pre><code class="python">@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 NotImplementedError</code></pre>
+    return self._album</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.artist"><code class="name">var <span class="ident">artist</span> -> Optional[str]</code></dt>
@@ -2508,11 +2546,10 @@ def album(self) -&gt; Optional[str]:
 <span>Expand source code</span>
 </summary>
 <pre><code class="python">@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 NotImplementedError</code></pre>
+    return self._artist</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>
@@ -2523,10 +2560,9 @@ def artist(self) -&gt; Optional[str]:
 <span>Expand source code</span>
 </summary>
 <pre><code class="python">@property
-@abstractmethod
 def device_state(self) -&gt; const.DeviceState:
     &#34;&#34;&#34;Device state, e.g. playing or paused.&#34;&#34;&#34;
-    raise NotImplementedError</code></pre>
+    return self._device_state</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.genre"><code class="name">var <span class="ident">genre</span> -> Optional[str]</code></dt>
@@ -2537,11 +2573,10 @@ def device_state(self) -&gt; const.DeviceState:
 <span>Expand source code</span>
 </summary>
 <pre><code class="python">@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 NotImplementedError</code></pre>
+    return self._genre</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.hash"><code class="name">var <span class="ident">hash</span> -> str</code></dt>
@@ -2560,6 +2595,9 @@ def hash(self) -&gt; str:
     The hash is based on title, artist, album and total time. It should
     always be the same for the same content, but it is not guaranteed.
     &#34;&#34;&#34;
+    if self._hash:
+        return self._hash
+
     base = &#34;{0}{1}{2}{3}&#34;.format(
         self.title, self.artist, self.album, self.total_time
     )
@@ -2574,13 +2612,12 @@ def hash(self) -&gt; str:
 <span>Expand source code</span>
 </summary>
 <pre><code class="python">@property
-@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 NotImplementedError</code></pre>
+    return self._media_type</code></pre>
 </details>
 </dd>
-<dt id="pyatv.interface.Playing.position"><code class="name">var <span class="ident">position</span> -> int</code></dt>
+<dt id="pyatv.interface.Playing.position"><code class="name">var <span class="ident">position</span> -> Optional[int]</code></dt>
 <dd>
 <section class="desc"><p>Position in the playing media (seconds).</p></section>
 <details class="source">
@@ -2588,14 +2625,13 @@ def media_type(self) -&gt; const.MediaType:
 <span>Expand source code</span>
 </summary>
 <pre><code class="python">@property  # type: ignore
-@abstractmethod
 @feature(27, &#34;Position&#34;, &#34;Current play time position.&#34;)
-def position(self) -&gt; int:
+def position(self) -&gt; Optional[int]:
     &#34;&#34;&#34;Position in the playing media (seconds).&#34;&#34;&#34;
-    raise NotImplementedError</code></pre>
+    return self._position</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>
+<dt id="pyatv.interface.Playing.repeat"><code class="name">var <span class="ident">repeat</span> -> Optional[<a title="pyatv.const.RepeatState" href="const#pyatv.const.RepeatState">RepeatState</a>]</code></dt>
 <dd>
 <section class="desc"><p>Repeat mode.</p></section>
 <details class="source">
@@ -2603,14 +2639,13 @@ def position(self) -&gt; int:
 <span>Expand source code</span>
 </summary>
 <pre><code class="python">@property  # type: ignore
-@abstractmethod
 @feature(29, &#34;Repeat&#34;, &#34;Repeat state.&#34;)
-def repeat(self) -&gt; const.RepeatState:
+def repeat(self) -&gt; Optional[const.RepeatState]:
     &#34;&#34;&#34;Repeat mode.&#34;&#34;&#34;
-    raise NotImplementedError</code></pre>
+    return self._repeat</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>
+<dt id="pyatv.interface.Playing.shuffle"><code class="name">var <span class="ident">shuffle</span> -> Optional[<a title="pyatv.const.ShuffleState" href="const#pyatv.const.ShuffleState">ShuffleState</a>]</code></dt>
 <dd>
 <section class="desc"><p>If shuffle is enabled or not.</p></section>
 <details class="source">
@@ -2618,11 +2653,10 @@ def repeat(self) -&gt; const.RepeatState:
 <span>Expand source code</span>
 </summary>
 <pre><code class="python">@property  # type: ignore
-@abstractmethod
-@feature(28, &#34;Shuffle&#34;, &#34;Shuffle stat.e&#34;)
-def shuffle(self) -&gt; const.ShuffleState:
+@feature(28, &#34;Shuffle&#34;, &#34;Shuffle state.&#34;)
+def shuffle(self) -&gt; Optional[const.ShuffleState]:
     &#34;&#34;&#34;If shuffle is enabled or not.&#34;&#34;&#34;
-    raise NotImplementedError</code></pre>
+    return self._shuffle</code></pre>
 </details>
 </dd>
 <dt id="pyatv.interface.Playing.title"><code class="name">var <span class="ident">title</span> -> Optional[str]</code></dt>
@@ -2633,14 +2667,13 @@ def shuffle(self) -&gt; const.ShuffleState:
 <span>Expand source code</span>
 </summary>
 <pre><code class="python">@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 NotImplementedError</code></pre>
+    return self._title</code></pre>
 </details>
 </dd>
-<dt id="pyatv.interface.Playing.total_time"><code class="name">var <span class="ident">total_time</span> -> int</code></dt>
+<dt id="pyatv.interface.Playing.total_time"><code class="name">var <span class="ident">total_time</span> -> Optional[int]</code></dt>
 <dd>
 <section class="desc"><p>Total play time in seconds.</p></section>
 <details class="source">
@@ -2648,11 +2681,10 @@ def title(self) -&gt; Optional[str]:
 <span>Expand source code</span>
 </summary>
 <pre><code class="python">@property  # type: ignore
-@abstractmethod
 @feature(26, &#34;TotalTime&#34;, &#34;Total length of playing media (seconds).&#34;)
-def total_time(self) -&gt; int:
+def total_time(self) -&gt; Optional[int]:
     &#34;&#34;&#34;Total play time in seconds.&#34;&#34;&#34;
-    raise NotImplementedError</code></pre>
+    return self._total_time</code></pre>
 </details>
 </dd>
 </dl>
diff --git a/pyatv/dmap/__init__.py b/pyatv/dmap/__init__.py
index 6f3d3fe4..348e814d 100644
--- a/pyatv/dmap/__init__.py
+++ b/pyatv/dmap/__init__.py
@@ -13,6 +13,7 @@ from pyatv.support.cache import Cache
 from pyatv.const import (
     Protocol,
     MediaType,
+    DeviceState,
     RepeatState,
     ShuffleState,
     PowerState,
@@ -90,6 +91,94 @@ _FIELD_FEATURES = {
 }  # type: Dict[FeatureName, tuple]
 
 
+def build_playing_instance(playstatus) -> Playing:
+    """Build a Playing instance from play state."""
+
+    def _get_time_in_seconds(tag) -> int:
+        time = parser.first(playstatus, "cmst", tag)
+        return daap.ms_to_s(time)
+
+    def media_type() -> MediaType:
+        """Type of media is currently playing, e.g. video, music."""
+        state = parser.first(playstatus, "cmst", "caps")
+        if not state:
+            return MediaType.Unknown
+
+        mediakind = parser.first(playstatus, "cmst", "cmmk")
+        if mediakind is not None:
+            return daap.media_kind(mediakind)
+
+        # Fallback: if artist or album exists we assume music (not present
+        # for video)
+        if artist() or album():
+            return MediaType.Music
+
+        return MediaType.Video
+
+    def device_state() -> DeviceState:
+        """Device state, e.g. playing or paused."""
+        state = parser.first(playstatus, "cmst", "caps")
+        return daap.playstate(state)
+
+    def title() -> Optional[str]:
+        """Title of the current media, e.g. movie or song name."""
+        return parser.first(playstatus, "cmst", "cann")
+
+    def artist() -> Optional[str]:
+        """Arist of the currently playing song."""
+        return parser.first(playstatus, "cmst", "cana")
+
+    def album() -> Optional[str]:
+        """Album of the currently playing song."""
+        return parser.first(playstatus, "cmst", "canl")
+
+    def genre() -> Optional[str]:
+        """Genre of the currently playing song."""
+        return parser.first(playstatus, "cmst", "cang")
+
+    def total_time() -> Optional[int]:
+        """Total play time in seconds."""
+        return _get_time_in_seconds("cast")
+
+    def position() -> Optional[int]:
+        """Position in the playing media (seconds)."""
+        total = total_time()
+        remaining_time = _get_time_in_seconds("cant")
+        if not total or not remaining_time:
+            return None
+        return total - remaining_time
+
+    def shuffle() -> Optional[ShuffleState]:
+        """If shuffle is enabled or not."""
+        state = parser.first(playstatus, "cmst", "cash")
+        if state is None or state == 0:
+            return ShuffleState.Off
+
+        # DMAP does not support the "albums" state and will always report
+        # "songs" if shuffle is active
+        return ShuffleState.Songs
+
+    def repeat() -> Optional[RepeatState]:
+        """Repeat mode."""
+        state = parser.first(playstatus, "cmst", "carp")
+        if state is None:
+            return RepeatState.Off
+        return RepeatState(state)
+
+    return Playing(
+        media_type=media_type(),
+        device_state=device_state(),
+        title=title(),
+        artist=artist(),
+        album=album(),
+        genre=genre(),
+        total_time=total_time(),
+        position=position(),
+        shuffle=shuffle(),
+        repeat=repeat(),
+    )
+
+
 class BaseDmapAppleTV:
     """Common protocol logic used to interact with an Apple TV."""
 
@@ -97,6 +186,7 @@ class BaseDmapAppleTV:
         """Initialize a new Apple TV base implementation."""
         self.daap = requester
         self.playstatus_revision = 0
+        self.latest_playstatus = None
         self.latest_playing = None
         self.latest_hash = None
 
@@ -111,7 +201,8 @@ class BaseDmapAppleTV:
         cmd_url = _PSU_CMD.format(self.playstatus_revision if use_revision else 0)
         resp = await self.daap.get(cmd_url, timeout=timeout)
         self.playstatus_revision = parser.first(resp, "cmst", "cmsr")
-        self.latest_playing = DmapPlaying(resp)
+        self.latest_playstatus = resp
+        self.latest_playing = build_playing_instance(resp)
         self.latest_hash = self.latest_playing.hash
         return self.latest_playing
 
@@ -306,92 +397,6 @@ class DmapRemoteControl(RemoteControl):
         await self.apple_tv.set_property("dacp.repeatstate", repeat_state.value)
 
 
-class DmapPlaying(Playing):
-    """Implementation of API for retrieving what is playing."""
-
-    def __init__(self, playstatus):
-        """Initialize playing instance."""
-        super().__init__()
-        self.playstatus = playstatus
-
-    @property
-    def media_type(self):
-        """Type of media is currently playing, e.g. video, music."""
-        state = parser.first(self.playstatus, "cmst", "caps")
-        if not state:
-            return MediaType.Unknown
-
-        mediakind = parser.first(self.playstatus, "cmst", "cmmk")
-        if mediakind is not None:
-            return daap.media_kind(mediakind)
-
-        # Fallback: if artist or album exists we assume music (not present
-        # for video)
-        if self.artist or self.album:
-            return MediaType.Music
-
-        return MediaType.Video
-
-    @property
-    def device_state(self):
-        """Device state, e.g. playing or paused."""
-        state = parser.first(self.playstatus, "cmst", "caps")
-        return daap.playstate(state)
-
-    @property
-    def title(self):
-        """Title of the current media, e.g. movie or song name."""
-        return parser.first(self.playstatus, "cmst", "cann")
-
-    @property
-    def artist(self):
-        """Arist of the currently playing song."""
-        return parser.first(self.playstatus, "cmst", "cana")
-
-    @property
-    def album(self):
-        """Album of the currently playing song."""
-        return parser.first(self.playstatus, "cmst", "canl")
-
-    @property
-    def genre(self):
-        """Genre of the currently playing song."""
-        return parser.first(self.playstatus, "cmst", "cang")
-
-    @property
-    def total_time(self):
-        """Total play time in seconds."""
-        return self._get_time_in_seconds("cast")
-
-    @property
-    def position(self):
-        """Position in the playing media (seconds)."""
-        return self.total_time - self._get_time_in_seconds("cant")
-
-    @property
-    def shuffle(self):
-        """If shuffle is enabled or not."""
-        state = parser.first(self.playstatus, "cmst", "cash")
-        if state is None or state == 0:
-            return ShuffleState.Off
-
-        # DMAP does not support the "albums" state and will always report
-        # "songs" if shuffle is active
-        return ShuffleState.Songs
-
-    @property
-    def repeat(self):
-        """Repeat mode."""
-        state = parser.first(self.playstatus, "cmst", "carp")
-        if state is None:
-            return RepeatState.Off
-        return RepeatState(state)
-
-    def _get_time_in_seconds(self, tag):
-        time = parser.first(self.playstatus, "cmst", tag)
-        return daap.ms_to_s(time)
-
-
 class DmapMetadata(Metadata):
     """Implementation of API for retrieving metadata from an Apple TV."""
 
@@ -563,8 +568,8 @@ class DmapFeatures(Features):
         return FeatureInfo(state=FeatureState.Unsupported)
 
     def _is_available(self, field: tuple, expected_value=None) -> FeatureState:
-        if self.apple_tv.latest_playing:
-            value = parser.first(self.apple_tv.latest_playing.playstatus, *field)
+        if self.apple_tv.latest_playstatus:
+            value = parser.first(self.apple_tv.latest_playstatus, *field)
             if value is not None:
                 if not expected_value or expected_value == value:
                     return FeatureState.Available
diff --git a/pyatv/dmap/daap.py b/pyatv/dmap/daap.py
index 8f4e6d63..311b1191 100644
--- a/pyatv/dmap/daap.py
+++ b/pyatv/dmap/daap.py
@@ -4,6 +4,7 @@ import re
 import logging
 
 from copy import copy
+from typing import Optional
 
 
 from pyatv import exceptions
@@ -42,7 +43,7 @@ def media_kind(kind):
     raise exceptions.UnknownMediaKindError("Unknown media kind: " + str(kind))
 
 
-def playstate(state):
+def playstate(state: Optional[int]) -> DeviceState:
     """Convert iTunes playstate to API representation."""
     # pylint: disable=too-many-return-statements
     if state == 0 or state is None:
diff --git a/pyatv/interface.py b/pyatv/interface.py
index 31a02e81..9cf69105 100644
--- a/pyatv/interface.py
+++ b/pyatv/interface.py
@@ -415,8 +415,36 @@ class Playing(ABC):
         "position",
         "shuffle",
         "repeat",
+        "hash",
     ]
 
+    def __init__(
+        self,
+        media_type: const.MediaType = const.MediaType.Unknown,
+        device_state: const.DeviceState = const.DeviceState.Idle,
+        title: Optional[str] = None,
+        artist: Optional[str] = None,
+        album: Optional[str] = None,
+        genre: Optional[str] = None,
+        total_time: Optional[int] = None,
+        position: Optional[int] = None,
+        shuffle: Optional[const.ShuffleState] = None,
+        repeat: Optional[const.RepeatState] = None,
+        hash: Optional[str] = None,
+    ) -> None:
+        """Initialize a new Playing instance."""
+        self._media_type = media_type
+        self._device_state = device_state
+        self._title = title
+        self._artist = artist
+        self._album = album
+        self._genre = genre
+        self._total_time = total_time
+        self._position = position
+        self._shuffle = shuffle
+        self._repeat = repeat
+        self._hash = hash
+
     def __str__(self) -> str:
         """Convert this playing object to a readable string."""
         output = []
@@ -476,78 +504,71 @@ class Playing(ABC):
         The hash is based on title, artist, album and total time. It should
         always be the same for the same content, but it is not guaranteed.
         """
+        if self._hash:
+            return self._hash
+
         base = "{0}{1}{2}{3}".format(
             self.title, self.artist, self.album, self.total_time
         )
         return hashlib.sha256(base.encode("utf-8")).hexdigest()
 
     @property
-    @abstractmethod
     def media_type(self) -> const.MediaType:
         """Type of media is currently playing, e.g. video, music."""
-        raise NotImplementedError
+        return self._media_type
 
     @property
-    @abstractmethod
     def device_state(self) -> const.DeviceState:
         """Device state, e.g. playing or paused."""
-        raise NotImplementedError
+        return self._device_state
 
     @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 NotImplementedError
+        return self._title
 
     @property  # type: ignore
-    @abstractmethod
     @feature(23, "Artist", "Artist of playing song.")
     def artist(self) -> Optional[str]:
         """Artist of the currently playing song."""
-        raise NotImplementedError
+        return self._artist
 
     @property  # type: ignore
-    @abstractmethod
     @feature(24, "Album", "Album from playing artist.")
     def album(self) -> Optional[str]:
         """Album of the currently playing song."""
-        raise NotImplementedError
+        return self._album
 
     @property  # type: ignore
-    @abstractmethod
     @feature(25, "Genre", "Genre of playing song.")
     def genre(self) -> Optional[str]:
         """Genre of the currently playing song."""
-        raise NotImplementedError
+        return self._genre
 
     @property  # type: ignore
-    @abstractmethod
     @feature(26, "TotalTime", "Total length of playing media (seconds).")
-    def total_time(self) -> int:
+    def total_time(self) -> Optional[int]:
         """Total play time in seconds."""
-        raise NotImplementedError
+        return self._total_time
 
     @property  # type: ignore
-    @abstractmethod
     @feature(27, "Position", "Current play time position.")
-    def position(self) -> int:
+    def position(self) -> Optional[int]:
         """Position in the playing media (seconds)."""
-        raise NotImplementedError
+        return self._position
 
     @property  # type: ignore
-    @abstractmethod
-    @feature(28, "Shuffle", "Shuffle stat.e")
-    def shuffle(self) -> const.ShuffleState:
+    @feature(28, "Shuffle", "Shuffle state.")
+    def shuffle(self) -> Optional[const.ShuffleState]:
         """If shuffle is enabled or not."""
-        raise NotImplementedError
+        return self._shuffle
 
     @property  # type: ignore
-    @abstractmethod
     @feature(29, "Repeat", "Repeat state.")
-    def repeat(self) -> const.RepeatState:
+    def repeat(self) -> Optional[const.RepeatState]:
         """Repeat mode."""
-        raise NotImplementedError
+        return self._repeat
 
 
 class App:
diff --git a/pyatv/mrp/__init__.py b/pyatv/mrp/__init__.py
index 26f85ffb..94e5c350 100644
--- a/pyatv/mrp/__init__.py
+++ b/pyatv/mrp/__init__.py
@@ -4,7 +4,6 @@ import math
 import logging
 import asyncio
 import datetime
-from copy import copy
 from typing import Dict, List, Optional, Tuple
 
 from pyatv import conf, exceptions
@@ -24,9 +23,9 @@ from pyatv.mrp import messages, protobuf
 from pyatv.mrp.srp import SRPAuthHandler
 from pyatv.mrp.connection import MrpConnection
 from pyatv.mrp.protocol import MrpProtocol
-from pyatv.mrp.protobuf import CommandInfo_pb2
-from pyatv.mrp.protobuf import PlaybackState
-from pyatv.mrp.player_state import PlayerStateManager
+from pyatv.mrp.protobuf import CommandInfo_pb2, PlaybackState
+from pyatv.mrp.protobuf import ContentItemMetadata as cim
+from pyatv.mrp.player_state import PlayerStateManager, PlayerState
 from pyatv.interface import (
     AppleTV,
     DeviceInfo,
@@ -119,6 +118,114 @@ def _cocoa_to_timestamp(time):
     return datetime.datetime.fromtimestamp(time_seconds)
 
 
+def build_playing_instance(state: PlayerState) -> Playing:
+    """Build a Playing instance from play state."""
+
+    def media_type() -> MediaType:
+        """Type of media is currently playing, e.g. video, music."""
+        if state.metadata:
+            media_type = state.metadata.mediaType
+            if media_type == cim.Audio:
+                return MediaType.Music
+            if media_type == cim.Video:
+                return MediaType.Video
+
+        return MediaType.Unknown
+
+    def device_state() -> DeviceState:
+        """Device state, e.g. playing or paused."""
+        return {
+            None: DeviceState.Idle,
+            PlaybackState.Playing: DeviceState.Playing,
+            PlaybackState.Paused: DeviceState.Paused,
+            PlaybackState.Stopped: DeviceState.Stopped,
+            PlaybackState.Interrupted: DeviceState.Loading,
+            PlaybackState.Seeking: DeviceState.Seeking,
+        }.get(state.playback_state, DeviceState.Paused)
+
+    def title() -> Optional[str]:
+        """Title of the current media, e.g. movie or song name."""
+        return state.metadata_field("title")
+
+    def artist() -> Optional[str]:
+        """Artist of the currently playing song."""
+        return state.metadata_field("trackArtistName")
+
+    def album() -> Optional[str]:
+        """Album of the currently playing song."""
+        return state.metadata_field("albumName")
+
+    def genre() -> Optional[str]:
+        """Genre of the currently playing song."""
+        return state.metadata_field("genre")
+
+    def total_time() -> Optional[int]:
+        """Total play time in seconds."""
+        duration = state.metadata_field("duration")
+        if duration is None or math.isnan(duration):
+            return None
+        return int(duration)
+
+    def position() -> Optional[int]:
+        """Position in the playing media (seconds)."""
+        elapsed_timestamp = state.metadata_field("elapsedTimeTimestamp")
+
+        # If we don't have reference time, we can't do anything
+        if not elapsed_timestamp:
+            return None
+
+        elapsed_time: int = state.metadata_field("elapsedTime") or 0
+        diff = (
+            datetime.datetime.now() - _cocoa_to_timestamp(elapsed_timestamp)
+        ).total_seconds()
+
+        if device_state() == DeviceState.Playing:
+            return int(elapsed_time + diff)
+        return int(elapsed_time)
+
+    def shuffle() -> ShuffleState:
+        """If shuffle is enabled or not."""
+        info = state.command_info(CommandInfo_pb2.ChangeShuffleMode)
+        if info is None:
+            return ShuffleState.Off
+        if info.shuffleMode == protobuf.ShuffleMode.Off:
+            return ShuffleState.Off
+        if info.shuffleMode == protobuf.ShuffleMode.Albums:
+            return ShuffleState.Albums
+
+        return ShuffleState.Songs
+
+    def repeat() -> RepeatState:
+        """Repeat mode."""
+        info = state.command_info(CommandInfo_pb2.ChangeRepeatMode)
+        if info is None:
+            return RepeatState.Off
+        if info.repeatMode == protobuf.RepeatMode.One:
+            return RepeatState.Track
+        if info.repeatMode == protobuf.RepeatMode.All:
+            return RepeatState.All
+
+        return RepeatState.Off
+
+    def hash() -> str:
+        """Create a unique hash for what is currently playing."""
+        return state.item_identifier
+
+    return Playing(
+        media_type=media_type(),
+        device_state=device_state(),
+        title=title(),
+        artist=artist(),
+        album=album(),
+        genre=genre(),
+        total_time=total_time(),
+        position=position(),
+        shuffle=shuffle(),
+        repeat=repeat(),
+        hash=hash(),
+    )
+
+
 # pylint: disable=too-many-public-methods
 class MrpRemoteControl(RemoteControl):
     """Implementation of API for controlling an Apple TV."""
@@ -302,116 +409,6 @@ class MrpRemoteControl(RemoteControl):
         await self.protocol.send_and_receive(messages.repeat(repeat_state))
 
 
-class MrpPlaying(Playing):
-    """Implementation of API for retrieving what is playing."""
-
-    def __init__(self, state):
-        """Initialize a new MrpPlaying."""
-        self._state = state
-
-    @property
-    def media_type(self):
-        """Type of media is currently playing, e.g. video, music."""
-        if self._state.metadata:
-            media_type = self._state.metadata.mediaType
-            cim = protobuf.ContentItemMetadata
-            if media_type == cim.Audio:
-                return MediaType.Music
-            if media_type == cim.Video:
-                return MediaType.Video
-
-        return MediaType.Unknown
-
-    @property
-    def device_state(self):
-        """Device state, e.g. playing or paused."""
-        return {
-            None: DeviceState.Idle,
-            PlaybackState.Playing: DeviceState.Playing,
-            PlaybackState.Paused: DeviceState.Paused,
-            PlaybackState.Stopped: DeviceState.Stopped,
-            PlaybackState.Interrupted: DeviceState.Loading,
-            PlaybackState.Seeking: DeviceState.Seeking,
-        }.get(self._state.playback_state, DeviceState.Paused)
-
-    @property
-    def title(self):
-        """Title of the current media, e.g. movie or song name."""
-        return self._state.metadata_field("title")
-
-    @property
-    def artist(self):
-        """Artist of the currently playing song."""
-        return self._state.metadata_field("trackArtistName")
-
-    @property
-    def album(self):
-        """Album of the currently playing song."""
-        return self._state.metadata_field("albumName")
-
-    @property
-    def genre(self):
-        """Genre of the currently playing song."""
-        return self._state.metadata_field("genre")
-
-    @property
-    def total_time(self):
-        """Total play time in seconds."""
-        duration = self._state.metadata_field("duration")
-        if duration is None or math.isnan(duration):
-            return None
-        return int(duration)
-
-    @property
-    def position(self):
-        """Position in the playing media (seconds)."""
-        elapsed_timestamp = self._state.metadata_field("elapsedTimeTimestamp")
-
-        # If we don't have reference time, we can't do anything
-        if not elapsed_timestamp:
-            return None
-
-        elapsed_time = self._state.metadata_field("elapsedTime") or 0
-        diff = (
-            datetime.datetime.now() - _cocoa_to_timestamp(elapsed_timestamp)
-        ).total_seconds()
-
-        if self.device_state == DeviceState.Playing:
-            return int(elapsed_time + diff)
-        return int(elapsed_time)
-
-    @property
-    def shuffle(self):
-        """If shuffle is enabled or not."""
-        info = self._state.command_info(CommandInfo_pb2.ChangeShuffleMode)
-        if info is None:
-            return ShuffleState.Off
-        if info.shuffleMode == protobuf.ShuffleMode.Off:
-            return ShuffleState.Off
-        if info.shuffleMode == protobuf.ShuffleMode.Albums:
-            return ShuffleState.Albums
-
-        return ShuffleState.Songs
-
-    @property
-    def repeat(self):
-        """Repeat mode."""
-        info = self._state.command_info(CommandInfo_pb2.ChangeRepeatMode)
-        if info is None:
-            return RepeatState.Off
-        if info.repeatMode == protobuf.RepeatMode.One:
-            return RepeatState.Track
-        if info.repeatMode == protobuf.RepeatMode.All:
-            return RepeatState.All
-
-        return RepeatState.Off
-
-    @property
-    def hash(self):
-        """Create a unique hash for what is currently playing."""
-        return self._state.item_identifier or super().hash
-
-
 class MrpMetadata(Metadata):
     """Implementation of API for retrieving metadata."""
 
@@ -475,9 +472,9 @@ class MrpMetadata(Metadata):
             return self.psm.playing.item_identifier
         return None
 
-    async def playing(self):
+    async def playing(self) -> Playing:
         """Return what is currently playing."""
-        return MrpPlaying(copy(self.psm.playing))
+        return build_playing_instance(self.psm.playing)
 
     @property
     def app(self) -> Optional[App]:


Test patch:
diff --git a/tests/mrp/test_mrp_functional.py b/tests/mrp/test_mrp_functional.py
index 760f4cd3..c9b3d46a 100644
--- a/tests/mrp/test_mrp_functional.py
+++ b/tests/mrp/test_mrp_functional.py
@@ -435,3 +435,13 @@ class MRPFunctionalTest(common_functional_tests.CommonFunctionalTests):
 
         await self.playing(title="dummy")
         self.assertFeatures(FeatureState.Available, FeatureName.Play, FeatureName.Pause)
+
+    @unittest_run_loop
+    async def test_playing_immutable_update_content_item(self):
+        self.usecase.example_video(position=1)
+        playing = await self.playing(title="dummy")
+
+        self.usecase.change_metadata(position=100)
+        await self.playing(position=100)
+
+        self.assertEqual(playing.position, 1)
diff --git a/tests/test_interface.py b/tests/test_interface.py
index c8f80a39..9fa75a21 100644
--- a/tests/test_interface.py
+++ b/tests/test_interface.py
@@ -5,7 +5,7 @@ import pytest
 from typing import Dict, Optional
 
 from pyatv import convert, exceptions, interface
-from pyatv.interface import FeatureInfo
+from pyatv.interface import FeatureInfo, Playing
 from pyatv.const import (
     MediaType,
     DeviceState,
@@ -31,6 +31,7 @@ eq_test_cases = [
     ("position", 555, 888),
     ("shuffle", ShuffleState.Albums, ShuffleState.Songs),
     ("repeat", RepeatState.Track, RepeatState.All),
+    ("hash", "hash1", "hash2"),
 ]
 
 
@@ -64,82 +65,6 @@ class TestClass:
         pass
 
 
-class PlayingDummy(interface.Playing):
-    def __init__(
-        self,
-        media_type=MediaType.Video,
-        device_state=DeviceState.Playing,
-        title=None,
-        artist=None,
-        album=None,
-        genre=None,
-        total_time=None,
-        position=None,
-        shuffle=ShuffleState.Songs,
-        repeat=RepeatState.Track,
-    ):
-        self._media_type = media_type
-        self._device_state = device_state
-        self._title = title
-        self._artist = artist
-        self._album = album
-        self._genre = genre
-        self._total_time = total_time
-        self._position = position
-        self._shuffle = shuffle
-        self._repeat = repeat
-
-    @property
-    def media_type(self):
-        """What type of media is currently playing, e.g. video, music."""
-        return self._media_type
-
-    @property
-    def device_state(self):
-        """Current device state, e.g. playing or paused."""
-        return self._device_state
-
-    @property
-    def title(self):
-        """Title of the current media, e.g. movie or song name."""
-        return self._title
-
-    @property
-    def artist(self):
-        """Artist of the currently playing song."""
-        return self._artist
-
-    @property
-    def album(self):
-        """Album of the currently playing song."""
-        return self._album
-
-    @property
-    def genre(self):
-        """Genre of the currently playing song."""
-        return self._genre
-
-    @property
-    def total_time(self):
-        """Total play time in seconds."""
-        return self._total_time
-
-    @property
-    def position(self):
-        """Current position in the playing media (seconds)."""
-        return self._position
-
-    @property
-    def shuffle(self):
-        """If shuffle is enabled or not."""
-        return self._shuffle
-
-    @property
-    def repeat(self):
-        """Current repeat mode."""
-        return self._repeat
-
-
 class MetadataDummy(interface.Metadata):
     def artwork(self):
         """Return artwork for what is currently playing (or None)."""
@@ -220,16 +145,14 @@ def test_try_to_be_smart_with_abbreviations(methods):
 
 
 def test_playing_media_type_and_playstate():
-    out = str(PlayingDummy())
+    out = str(Playing(media_type=MediaType.Video, device_state=DeviceState.Playing))
     assert convert.media_type_str(MediaType.Video) in out
     assert convert.device_state_str(DeviceState.Playing) in out
 
 
 def test_playing_title_artist_album_genre():
     out = str(
-        PlayingDummy(
-            title="mytitle", artist="myartist", album="myalbum", genre="mygenre"
-        )
+        Playing(title="mytitle", artist="myartist", album="myalbum", genre="mygenre")
     )
     assert "mytitle" in out
     assert "myartist" in out
@@ -238,34 +161,32 @@ def test_playing_title_artist_album_genre():
 
 
 def test_playing_only_position():
-    assert "1234" in str(PlayingDummy(position=1234))
+    assert "1234" in str(Playing(position=1234))
 
 
 def test_playing_only_total_time():
-    assert "5678" in str(PlayingDummy(total_time=5678))
+    assert "5678" in str(Playing(total_time=5678))
 
 
 def test_playing_both_position_and_total_time():
-    out = str(PlayingDummy(position=1234, total_time=5678))
+    out = str(Playing(position=1234, total_time=5678))
     assert "1234/5678" in out
 
 
 def test_playing_shuffle_and_repeat():
-    out = str(PlayingDummy())
+    out = str(Playing(shuffle=ShuffleState.Songs, repeat=RepeatState.Track))
     assert "Shuffle: Songs" in out
     assert "Repeat: Track" in out
 
 
 def test_playing_generate_same_hash():
-    playing = PlayingDummy(
-        title="title", artist="artist", album="album", total_time=123
-    )
+    playing = Playing(title="title", artist="artist", album="album", total_time=123)
     assert (
         "538df531d1715629fdd87affd0c5957bcbf54cd89180778071e6535b7df4e22c"
         == playing.hash
     )
 
-    playing2 = PlayingDummy(title="dummy", artist="test", album="none", total_time=321)
+    playing2 = Playing(title="dummy", artist="test", album="none", total_time=321)
 
     assert (
         "80045c05d18382f33a5369fd5cdfc6ae42c3eb418125f638d7a31ab173b01ade"
@@ -273,10 +194,15 @@ def test_playing_generate_same_hash():
     )
 
 
+def test_playing_custom_hash():
+    playing = Playing(hash="dummy")
+    assert playing.hash == "dummy"
+
+
 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
+    assert len(Playing().__dict__) == 11
 
 
 @pytest.mark.parametrize(
@@ -284,15 +210,24 @@ def test_playing_eq_ensure_member_count():
     [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})
+    playing1 = Playing(**{prop: value1})
+    playing2 = Playing(**{prop: value2})
+    playing3 = Playing(**{prop: value2})
 
     assert playing1 == playing1
     assert playing1 != playing2
     assert playing2 == playing3
 
 
+@pytest.mark.parametrize(
+    "prop,value1,value2",
+    [pytest.param(*data, id=data[0]) for data in eq_test_cases],
+)
+def test_playing_init_field_values(prop, value1, value2):
+    playing = Playing(**{prop: value1})
+    assert getattr(playing, prop) == value1
+
+
 # METADATA
 
 
@@ -424,7 +359,7 @@ def test_app_str():
 @pytest.mark.parametrize("updates", [1, 2, 3])
 def test_post_ignore_duplicate_update(event_loop, updates):
     listener = MagicMock()
-    playing = PlayingDummy()
+    playing = Playing()
 
     async def _post_updates(repeats: int):
         updater = PushUpdaterDummy(event_loop)