Plex: Add real-time playback state reporting and timeline updates (#2512)
authoranatosun <33899455+anatosun@users.noreply.github.com>
Tue, 21 Oct 2025 17:14:05 +0000 (19:14 +0200)
committerGitHub <noreply@github.com>
Tue, 21 Oct 2025 17:14:05 +0000 (19:14 +0200)
music_assistant/providers/plex/__init__.py

index c7d44b4563ca6b36e6a767b9bc120f702b1aa845..4724ce6c8eee7a7362eb88a030b5eb3a1e920f07 100644 (file)
@@ -38,6 +38,7 @@ from music_assistant_models.media_items import (
     ItemMapping,
     MediaItem,
     MediaItemImage,
+    MediaItemType,
     Playlist,
     ProviderMapping,
     SearchResults,
@@ -377,6 +378,15 @@ class PlexProvider(MusicProvider):
                     if self.config.get_value(CONF_LOCAL_SERVER_SSL)
                     else False
                 )
+                # Add Music Assistant client identification headers
+                session.headers.update(
+                    {
+                        "X-Plex-Client-Identifier": self.instance_id,
+                        "X-Plex-Product": "Music Assistant",
+                        "X-Plex-Platform": "Music Assistant",
+                        "X-Plex-Version": self.mass.version,
+                    }
+                )
                 local_server_protocol = (
                     "https" if self.config.get_value(CONF_LOCAL_SERVER_SSL) else "http"
                 )
@@ -387,7 +397,7 @@ class PlexProvider(MusicProvider):
                 )
                 if token == AUTH_TOKEN_UNAUTH:
                     # Doing local connection, not via plex.tv.
-                    plex_server = PlexServer(plex_url)
+                    plex_server = PlexServer(plex_url, session=session)
                 else:
                     plex_server = PlexServer(
                         plex_url,
@@ -1129,15 +1139,160 @@ class PlexProvider(MusicProvider):
         """Handle callback when an item completed streaming."""
 
         def mark_played() -> None:
-            item = streamdetails.data
-            params = {
-                "key": str(item.ratingKey),
-                "identifier": "com.plexapp.plugins.library",
-            }
-            self._plex_server.query("/:/scrobble", params=params)
+            """Mark the item as played in Plex."""
+            try:
+                item = streamdetails.data
+                if not item:
+                    self.logger.warning("No Plex item data in streamdetails, cannot scrobble")
+                    return
+
+                if not hasattr(item, "ratingKey"):
+                    self.logger.warning(
+                        "Streamdetails data is not a Plex item (missing ratingKey), cannot scrobble"
+                    )
+                    return
+
+                params = {
+                    "key": str(item.ratingKey),
+                    "identifier": "com.plexapp.plugins.library",
+                }
+                self.logger.debug(
+                    "Scrobbling track %s (ratingKey: %s) to Plex",
+                    streamdetails.uri,
+                    item.ratingKey,
+                )
+                self._plex_server.query("/:/scrobble", params=params)
+                self.logger.info("Successfully scrobbled track %s to Plex", streamdetails.uri)
+            except Exception as err:
+                self.logger.exception(
+                    "Failed to scrobble track %s to Plex: %s",
+                    streamdetails.uri,
+                    err,
+                )
 
         await asyncio.to_thread(mark_played)
 
+    async def on_played(
+        self,
+        media_type: MediaType,
+        prov_item_id: str,
+        fully_played: bool,
+        position: int,
+        media_item: MediaItemType,
+        is_playing: bool = False,
+    ) -> None:
+        """
+        Handle callback when a media item has been played.
+
+        This is called periodically (every 30s) during playback and when playback stops.
+        We use this to send timeline/progress updates to Plex.
+        """
+        if media_type != MediaType.TRACK:
+            # Only handle tracks for now
+            return
+
+        def update_timeline() -> None:
+            """Update Plex timeline with current playback progress."""
+            try:
+                self.logger.debug(
+                    "on_played: prov_item_id=%s, pos=%s, fully_played=%s, is_playing=%s",
+                    prov_item_id,
+                    position,
+                    fully_played,
+                    is_playing,
+                )
+
+                # Extract ratingKey from the key path (e.g., "/library/metadata/12345" -> "12345")
+                # The prov_item_id is the Plex key path, we need the ratingKey for API calls
+                try:
+                    rating_key = prov_item_id.split("/")[-1]
+                    self.logger.debug(
+                        "Extracted ratingKey %s from path %s", rating_key, prov_item_id
+                    )
+                except Exception as e:
+                    self.logger.error("Failed to extract ratingKey from %s: %s", prov_item_id, e)
+                    return
+
+                # Fetch the track directly from server using ratingKey to avoid ambiguity
+                # Using server.fetchItem() instead of library.fetchItem() is more reliable
+                plex_track = self._plex_server.fetchItem(int(rating_key))
+                if not plex_track:
+                    self.logger.warning("Cannot find Plex item with ratingKey %s", rating_key)
+                    return
+
+                self.logger.debug(
+                    "Found Plex item: '%s' by '%s' (type: %s, ratingKey: %s)",
+                    plex_track.title if hasattr(plex_track, "title") else "unknown",
+                    plex_track.grandparentTitle
+                    if hasattr(plex_track, "grandparentTitle")
+                    else "unknown",
+                    plex_track.type if hasattr(plex_track, "type") else "unknown",
+                    plex_track.ratingKey if hasattr(plex_track, "ratingKey") else "unknown",
+                )
+
+                # Verify this is actually a track, not a collection or other item
+                if not hasattr(plex_track, "type") or plex_track.type != "track":
+                    self.logger.warning(
+                        "Item %s is not a track (type: %s), cannot update timeline",
+                        rating_key,
+                        plex_track.type if hasattr(plex_track, "type") else "unknown",
+                    )
+                    return
+
+                # Convert position to milliseconds (Plex expects ms)
+                position_ms = position * 1000
+
+                # Determine playback state
+                if fully_played:
+                    state = "stopped"
+                elif is_playing:
+                    state = "playing"
+                else:
+                    state = "paused"
+
+                # Send timeline update to Plex with current state
+                # Client identification is set globally on the session headers
+                params = {
+                    "ratingKey": str(plex_track.ratingKey),
+                    "key": prov_item_id,
+                    "state": state,
+                    "time": str(position_ms),
+                    "duration": str(plex_track.duration)
+                    if hasattr(plex_track, "duration")
+                    else "0",
+                }
+                self.logger.debug("Sending Plex timeline update (state=%s): %s", state, params)
+                self._plex_server.query("/:/timeline", params=params)
+
+                # If fully played, also scrobble
+                if fully_played:
+                    scrobble_params = {
+                        "key": str(plex_track.ratingKey),
+                        "identifier": "com.plexapp.plugins.library",
+                    }
+                    self.logger.debug("Scrobbling track to Plex: %s", scrobble_params)
+                    self._plex_server.query("/:/scrobble", params=scrobble_params)
+                    self.logger.info("Track %s marked as played in Plex", prov_item_id)
+
+                # If position is 0 and not playing, mark as unplayed
+                if position == 0 and not is_playing and not fully_played:
+                    unscrobble_params = {
+                        "key": str(plex_track.ratingKey),
+                        "identifier": "com.plexapp.plugins.library",
+                    }
+                    self.logger.debug("Unscrobbling track in Plex: %s", unscrobble_params)
+                    self._plex_server.query("/:/unscrobble", params=unscrobble_params)
+                    self.logger.info("Track %s marked as unplayed in Plex", prov_item_id)
+
+            except Exception as err:
+                self.logger.exception(
+                    "Failed to update Plex timeline for track %s: %s",
+                    prov_item_id,
+                    err,
+                )
+
+        await asyncio.to_thread(update_timeline)
+
     async def get_myplex_account_and_refresh_token(self, auth_token: str) -> MyPlexAccount:
         """Get a MyPlexAccount object and refresh the token if needed."""
         if auth_token == AUTH_TOKEN_UNAUTH: