Repository: postlund/pyatv

Issue:
Wrong state reported for MRP
**Describe the bug**
Due to me misunderstanding how clients and players work, I introduced a bug when I re-wrote the player management. I assumed that a `SetNowPlayingPlayer` always changed the currently active player. That is not true as it only changes the active player for a particular client. A `SetNowPlayingClient` determines what is actually currently playing.

I need to change so that I keep track of an active player per client.


**To Reproduce**
This is quite easy to trigger by just watching content in various apps. After some time (usually quite fast), the bug will happen. The metadata will show something from another app usually.

**Expected behavior**
Correct metadata for what is currently playing is shown.


**System Setup (please complete the following information):**
 - OS: any
 - Python: any
 - pyatv: master
- Apple TV: Apple TV 4K tvOS 14.3

**Additional context**
Introduced in #915

Comments:

Patch:
diff --git a/pyatv/mrp/player_state.py b/pyatv/mrp/player_state.py
index d5e626cb..4615edb3 100644
--- a/pyatv/mrp/player_state.py
+++ b/pyatv/mrp/player_state.py
@@ -8,10 +8,12 @@ from copy import deepcopy
 from typing import Dict, Optional, List
 
 from pyatv.mrp import protobuf as pb
-
+from pyatv.mrp.protocol import MrpProtocol
 
 _LOGGER = logging.getLogger(__name__)
 
+DEFAULT_PLAYER_ID = "MediaRemote-DefaultPlayer"
+
 
 class PlayerState:
     """Represent what is currently playing on a device."""
@@ -129,21 +131,50 @@ class PlayerState:
 class Client:
     """Represent an MRP media player client."""
 
-    def __init__(self, client: pb.NowPlayingClient):
+    def __init__(self, client: pb.NowPlayingClient) -> None:
         """Initialize a new Client instance."""
         self.bundle_identifier: str = client.bundleIdentifier
         self.display_name: Optional[str] = None
+        self._active_player: Optional[PlayerState] = None
         self.players: Dict[str, PlayerState] = {}
         self.supported_commands: List[pb.CommandInfo] = []
         self.update(client)
 
-    def handle_set_default_supported_commands(self, supported_commands):
+    @property
+    def active_player(self) -> PlayerState:
+        """Return currently active player."""
+        if self._active_player is None:
+            if DEFAULT_PLAYER_ID in self.players:
+                return self.players[DEFAULT_PLAYER_ID]
+            return PlayerState(self, pb.NowPlayingPlayer())
+        return self._active_player
+
+    def get_player(self, player: pb.NowPlayingPlayer) -> PlayerState:
+        """Get state for a player."""
+        if player.identifier not in self.players:
+            self.players[player.identifier] = PlayerState(self, player)
+        return self.players[player.identifier]
+
+    def handle_set_default_supported_commands(self, supported_commands) -> None:
         """Update default supported commands for client."""
         self.supported_commands = deepcopy(
             supported_commands.supportedCommands.supportedCommands
         )
 
-    def update(self, client: pb.NowPlayingClient):
+    def handle_set_now_playing_player(self, player: pb.NowPlayingPlayer) -> None:
+        """Handle change of now playing player."""
+        self._active_player = self.get_player(player)
+
+        if self.active_player.is_valid:
+            _LOGGER.debug(
+                "Active player is now %s (%s)",
+                self.active_player.identifier,
+                self.active_player.display_name,
+            )
+        else:
+            _LOGGER.debug("Active player no longer set")
+
+    def update(self, client: pb.NowPlayingClient) -> None:
         """Update client metadata."""
         self.display_name = client.displayName or self.display_name
 
@@ -151,12 +182,11 @@ class Client:
 class PlayerStateManager:
     """Manage state of all media players."""
 
-    def __init__(self, protocol):
+    def __init__(self, protocol: MrpProtocol):
         """Initialize a new PlayerStateManager instance."""
         self.protocol = protocol
         self.volume_controls_available = None
         self._active_client = None
-        self._active_player = None
         self._clients: Dict[str, Client] = {}
         self._listener = None
         self._add_listeners()
@@ -185,12 +215,7 @@ class PlayerStateManager:
 
     def get_player(self, player_path: pb.PlayerPath) -> PlayerState:
         """Return player based on a player path."""
-        client = self.get_client(player_path.client)
-
-        player_id = player_path.player.identifier
-        if player_id not in client.players:
-            client.players[player_id] = PlayerState(client, player_path.player)
-        return client.players[player_id]
+        return self.get_client(player_path.client).get_player(player_path.player)
 
     @property
     def listener(self):
@@ -213,16 +238,10 @@ class PlayerStateManager:
         return self._active_client
 
     @property
-    def playing(self):
+    def playing(self) -> PlayerState:
         """Player state for active media player."""
-        if self._active_player:
-            return self._active_player
         if self._active_client:
-            default_player = self._active_client.players.get(
-                "MediaRemote-DefaultPlayer"
-            )
-            if default_player:
-                return default_player
+            return self._active_client.active_player
         return PlayerState(Client(pb.NowPlayingClient()), pb.NowPlayingPlayer())
 
     async def _handle_set_state(self, message, _):
@@ -249,18 +268,12 @@ class PlayerStateManager:
         await self._state_updated()
 
     async def _handle_set_now_playing_player(self, message, _):
-        self._active_player = self.get_player(message.inner().playerPath)
+        set_now_playing = message.inner()
 
-        if self._active_player.is_valid:
-            _LOGGER.debug(
-                "Active player is now %s (%s)",
-                self._active_player.identifier,
-                self._active_player.display_name,
-            )
-        else:
-            _LOGGER.debug("Active player no longer set")
+        client = self.get_client(set_now_playing.playerPath.client)
+        client.handle_set_now_playing_player(set_now_playing.playerPath.player)
 
-        await self._state_updated()
+        await self._state_updated(client=client)
 
     async def _handle_remove_client(self, message, _):
         client_to_remove = message.inner().client
@@ -282,16 +295,9 @@ class PlayerStateManager:
             del client.players[player.identifier]
             player.parent = None
 
-            removed = False
-            if player == self._active_player:
-                self._active_player = None
-                removed = True
-            if client == self._active_client:
-                self._active_client = None
-                removed = True
-
-            if removed:
-                await self._state_updated()
+            if player == client.active_player:
+                client._active_player = None
+                await self._state_updated(client=client)
 
     async def _handle_set_default_supported_commands(self, message, _):
         supported_commands = message.inner()


Test patch:
diff --git a/tests/mrp/test_player_state.py b/tests/mrp/test_player_state.py
index 7b4de2ff..9a7245dd 100644
--- a/tests/mrp/test_player_state.py
+++ b/tests/mrp/test_player_state.py
@@ -14,6 +14,8 @@ CLIENT_ID_2 = "client_id_2"
 PLAYER_ID_1 = "player_id_1"
 PLAYER_NAME_1 = "player_name_1"
 
+DEFAULT_PLAYER = "MediaRemote-DefaultPlayer"
+
 
 def set_path(
     message,
@@ -252,11 +254,27 @@ async def test_set_now_playing_client(psm, protocol, listener):
 
 
 @pytest.mark.asyncio
-async def test_set_now_playing_player(psm, protocol, listener):
+async def test_set_now_playing_player_when_no_client(psm, protocol, listener):
     msg = set_path(messages.create(pb.SET_NOW_PLAYING_PLAYER_MESSAGE))
     await protocol.inject(msg)
 
-    assert listener.call_count == 1
+    assert listener.call_count == 0
+
+    assert not psm.playing.identifier
+    assert not psm.playing.display_name
+
+
+@pytest.mark.asyncio
+async def test_set_now_playing_player_for_active_client(psm, protocol, listener):
+    msg = messages.create(pb.SET_NOW_PLAYING_CLIENT_MESSAGE)
+    client = msg.inner().client
+    client.bundleIdentifier = CLIENT_ID_1
+    await protocol.inject(msg)
+
+    msg = set_path(messages.create(pb.SET_NOW_PLAYING_PLAYER_MESSAGE))
+    await protocol.inject(msg)
+
+    assert listener.call_count == 2
 
     assert psm.playing.identifier == PLAYER_ID_1
     assert psm.playing.display_name == PLAYER_NAME_1
@@ -268,7 +286,7 @@ async def test_default_player_when_only_client_set(psm, protocol, listener):
     await protocol.inject(msg)
     msg = set_path(
         messages.create(pb.SET_STATE_MESSAGE),
-        player_id="MediaRemote-DefaultPlayer",
+        player_id=DEFAULT_PLAYER,
         player_name="Default Name",
     )
     await protocol.inject(msg)
@@ -278,7 +296,7 @@ async def test_default_player_when_only_client_set(psm, protocol, listener):
     client.bundleIdentifier = CLIENT_ID_1
     await protocol.inject(msg)
 
-    assert psm.playing.identifier == "MediaRemote-DefaultPlayer"
+    assert psm.playing.identifier == DEFAULT_PLAYER
     assert psm.playing.display_name == "Default Name"
 
 
@@ -289,14 +307,21 @@ async def test_set_state_calls_active_listener(psm, protocol, listener):
 
     assert listener.call_count == 1
 
+    msg = messages.create(pb.SET_NOW_PLAYING_CLIENT_MESSAGE)
+    client = msg.inner().client
+    client.bundleIdentifier = CLIENT_ID_1
+    await protocol.inject(msg)
+
+    assert listener.call_count == 2
+
     now_playing = set_path(messages.create(pb.SET_NOW_PLAYING_PLAYER_MESSAGE))
     await protocol.inject(now_playing)
 
-    assert listener.call_count == 2
+    assert listener.call_count == 3
 
     await protocol.inject(set_state)
 
-    assert listener.call_count == 3
+    assert listener.call_count == 4
 
 
 @pytest.mark.asyncio
@@ -312,14 +337,21 @@ async def test_content_item_update_calls_active_listener(psm, protocol, listener
 
     assert listener.call_count == 2
 
+    msg = messages.create(pb.SET_NOW_PLAYING_CLIENT_MESSAGE)
+    client = msg.inner().client
+    client.bundleIdentifier = CLIENT_ID_1
+    await protocol.inject(msg)
+
+    assert listener.call_count == 3
+
     now_playing = set_path(messages.create(pb.SET_NOW_PLAYING_PLAYER_MESSAGE))
     await protocol.inject(now_playing)
 
-    assert listener.call_count == 3
+    assert listener.call_count == 4
 
     await protocol.inject(update_item)
 
-    assert listener.call_count == 4
+    assert listener.call_count == 5
 
 
 @pytest.mark.asyncio
@@ -388,6 +420,11 @@ async def test_remove_active_player(psm, protocol, listener):
     msg = set_path(messages.create(pb.SET_STATE_MESSAGE))
     await protocol.inject(msg)
 
+    msg = messages.create(pb.SET_NOW_PLAYING_CLIENT_MESSAGE)
+    client = msg.inner().client
+    client.bundleIdentifier = CLIENT_ID_1
+    await protocol.inject(msg)
+
     msg = set_path(messages.create(pb.SET_NOW_PLAYING_PLAYER_MESSAGE))
     await protocol.inject(msg)
 
@@ -396,12 +433,12 @@ async def test_remove_active_player(psm, protocol, listener):
     remove = set_path(messages.create(pb.REMOVE_PLAYER_MESSAGE))
     await protocol.inject(remove)
 
-    assert listener.call_count == 3
+    assert listener.call_count == 4
     assert not psm.playing.is_valid
 
 
-async def test_remove_client_if_belongs_to_active_player(psm, protocol, listener):
-    msg = set_path(messages.create(pb.SET_STATE_MESSAGE))
+async def test_remove_active_player_reverts_to_default(psm, protocol, listener):
+    msg = set_path(messages.create(pb.SET_STATE_MESSAGE), player_id=DEFAULT_PLAYER)
     await protocol.inject(msg)
 
     msg = set_path(messages.create(pb.SET_NOW_PLAYING_PLAYER_MESSAGE))
@@ -412,11 +449,14 @@ async def test_remove_client_if_belongs_to_active_player(psm, protocol, listener
     client.bundleIdentifier = CLIENT_ID_1
     await protocol.inject(msg)
 
+    assert listener.call_count == 2
+    assert psm.playing.identifier == PLAYER_ID_1
+
     remove = set_path(messages.create(pb.REMOVE_PLAYER_MESSAGE))
     await protocol.inject(remove)
 
-    assert psm.client is None
-    assert listener.call_count == 4
+    assert listener.call_count == 3
+    assert psm.playing.identifier == DEFAULT_PLAYER
 
 
 @pytest.mark.asyncio