From: anatosun <33899455+anatosun@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:14:05 +0000 (+0200) Subject: Plex: Add real-time playback state reporting and timeline updates (#2512) X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=8024b34d6fb497b90ebbb8cb14689ff3e94db37d;p=music-assistant-server.git Plex: Add real-time playback state reporting and timeline updates (#2512) --- diff --git a/music_assistant/providers/plex/__init__.py b/music_assistant/providers/plex/__init__.py index c7d44b45..4724ce6c 100644 --- a/music_assistant/providers/plex/__init__.py +++ b/music_assistant/providers/plex/__init__.py @@ -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: