ItemMapping,
MediaItem,
MediaItemImage,
+ MediaItemType,
Playlist,
ProviderMapping,
SearchResults,
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"
)
)
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,
"""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: