From 653305b998dd033365576db303b32dd5df3a6c54 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 21 Mar 2022 17:48:44 -0500 Subject: [PATCH] Support multiple Plex servers in media browser (#68321) --- .coveragerc | 1 + homeassistant/components/plex/__init__.py | 2 +- homeassistant/components/plex/cast.py | 12 +- .../components/plex/media_browser.py | 215 +++++++++++------- homeassistant/components/plex/media_player.py | 54 +---- homeassistant/components/plex/models.py | 33 +++ homeassistant/components/plex/services.py | 71 ++++-- .../components/sonos/media_player.py | 19 +- tests/components/plex/test_browse_media.py | 41 ++-- tests/components/plex/test_playback.py | 84 ++++++- tests/components/plex/test_services.py | 73 ++++-- tests/components/sonos/test_plex_playback.py | 52 ++++- 12 files changed, 436 insertions(+), 221 deletions(-) diff --git a/.coveragerc b/.coveragerc index 3b99fac2c3b..1216a78370a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -896,6 +896,7 @@ omit = homeassistant/components/plaato/const.py homeassistant/components/plaato/entity.py homeassistant/components/plaato/sensor.py + homeassistant/components/plex/cast.py homeassistant/components/plex/media_player.py homeassistant/components/plex/view.py homeassistant/components/plugwise/select.py diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index df3a4b8cd11..e0a84ced16f 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -69,7 +69,7 @@ async def async_browse_media(hass, media_content_type, media_content_id, platfor return await hass.async_add_executor_job( partial( browse_media, - plex_server, + hass, is_internal, media_content_type, media_content_id, diff --git a/homeassistant/components/plex/cast.py b/homeassistant/components/plex/cast.py index 59f23a681f8..dc8d791f117 100644 --- a/homeassistant/components/plex/cast.py +++ b/homeassistant/components/plex/cast.py @@ -10,8 +10,7 @@ from homeassistant.components.media_player.const import MEDIA_CLASS_APP from homeassistant.core import HomeAssistant from . import async_browse_media as async_browse_plex_media, is_plex_media_id -from .const import PLEX_URI_SCHEME -from .services import lookup_plex_media +from .services import process_plex_payload async def async_get_media_browser_root_object( @@ -51,13 +50,10 @@ def _play_media( hass: HomeAssistant, chromecast: Chromecast, media_type: str, media_id: str ) -> None: """Play media.""" - media_id = media_id[len(PLEX_URI_SCHEME) :] - media = lookup_plex_media(hass, media_type, media_id) - if media is None: - return + result = process_plex_payload(hass, media_type, media_id) controller = PlexController() chromecast.register_handler(controller) - controller.play_media(media) + controller.play_media(result.media, offset=result.offset) async def async_play_media( @@ -68,7 +64,7 @@ async def async_play_media( media_id: str, ) -> bool: """Play media.""" - if media_id and media_id.startswith(PLEX_URI_SCHEME): + if is_plex_media_id(media_id): await hass.async_add_executor_job( _play_media, hass, chromecast, media_type, media_id ) diff --git a/homeassistant/components/plex/media_browser.py b/homeassistant/components/plex/media_browser.py index c50c173ec8d..4b2bb502d69 100644 --- a/homeassistant/components/plex/media_browser.py +++ b/homeassistant/components/plex/media_browser.py @@ -1,7 +1,7 @@ """Support to interface with the Plex API.""" from __future__ import annotations -import logging +from yarl import URL from homeassistant.components.media_player import BrowseMedia from homeassistant.components.media_player.const import ( @@ -18,7 +18,7 @@ from homeassistant.components.media_player.const import ( ) from homeassistant.components.media_player.errors import BrowseError -from .const import DOMAIN, PLEX_URI_SCHEME +from .const import DOMAIN, SERVERS from .errors import MediaNotFound from .helpers import pretty_title @@ -27,16 +27,7 @@ class UnknownMediaType(BrowseError): """Unknown media type.""" -HUB_PREFIX = "hub:" EXPANDABLES = ["album", "artist", "playlist", "season", "show"] -PLAYLISTS_BROWSE_PAYLOAD = { - "title": "Playlists", - "media_class": MEDIA_CLASS_DIRECTORY, - "media_content_id": PLEX_URI_SCHEME + "all", - "media_content_type": "playlists", - "can_play": False, - "can_expand": True, -} ITEM_TYPE_MEDIA_CLASS = { "album": MEDIA_CLASS_ALBUM, "artist": MEDIA_CLASS_ARTIST, @@ -52,25 +43,39 @@ ITEM_TYPE_MEDIA_CLASS = { "video": MEDIA_CLASS_VIDEO, } -_LOGGER = logging.getLogger(__name__) - def browse_media( # noqa: C901 - plex_server, is_internal, media_content_type, media_content_id, *, platform=None + hass, is_internal, media_content_type, media_content_id, *, platform=None ): """Implement the websocket media browsing helper.""" + server_id = None + plex_server = None + special_folder = None + + if media_content_id: + url = URL(media_content_id) + server_id = url.host + plex_server = hass.data[DOMAIN][SERVERS][server_id] + if media_content_type == "hub": + _, hub_location, hub_identifier = url.parts + elif media_content_type in ["library", "server"] and len(url.parts) > 2: + _, media_content_id, special_folder = url.parts + else: + media_content_id = url.name + + if media_content_type in ("plex_root", None): + return root_payload(hass, is_internal, platform=platform) def item_payload(item, short_name=False): """Create response payload for a single media item.""" try: media_class = ITEM_TYPE_MEDIA_CLASS[item.type] except KeyError as err: - _LOGGER.debug("Unknown type received: %s", item.type) - raise UnknownMediaType from err + raise UnknownMediaType("Unknown type received: {item.type}") from err payload = { "title": pretty_title(item, short_name), "media_class": media_class, - "media_content_id": PLEX_URI_SCHEME + str(item.ratingKey), + "media_content_id": generate_plex_uri(server_id, item.ratingKey), "media_content_type": item.type, "can_play": True, "can_expand": item.type in EXPANDABLES, @@ -81,16 +86,41 @@ def browse_media( # noqa: C901 thumbnail = item.thumbUrl else: thumbnail = get_proxy_image_url( - plex_server.machine_identifier, + server_id, item.ratingKey, ) payload["thumbnail"] = thumbnail return BrowseMedia(**payload) - def library_payload(library_id): + def server_payload(): + """Create response payload to describe libraries of the Plex server.""" + server_info = BrowseMedia( + title=plex_server.friendly_name, + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id=generate_plex_uri(server_id, "server"), + media_content_type="server", + can_play=False, + can_expand=True, + children=[], + children_media_class=MEDIA_CLASS_DIRECTORY, + thumbnail="https://brands.home-assistant.io/_/plex/logo.png", + ) + if platform != "sonos": + server_info.children.append( + special_library_payload(server_info, "Recommended") + ) + for library in plex_server.library.sections(): + if library.type == "photo": + continue + if library.type != "artist" and platform == "sonos": + continue + server_info.children.append(library_section_payload(library)) + server_info.children.append(playlists_payload()) + return server_info + + def library_contents(library): """Create response payload to describe contents of a specific library.""" - library = plex_server.library.sectionByID(library_id) library_info = library_section_payload(library) library_info.children = [special_library_payload(library_info, "Recommended")] for item in library.all(): @@ -100,9 +130,17 @@ def browse_media( # noqa: C901 continue return library_info - def playlists_payload(platform): + def playlists_payload(): """Create response payload for all available playlists.""" - playlists_info = {**PLAYLISTS_BROWSE_PAYLOAD, "children": []} + playlists_info = { + "title": "Playlists", + "media_class": MEDIA_CLASS_DIRECTORY, + "media_content_id": generate_plex_uri(server_id, "all"), + "media_content_type": "playlists", + "can_play": False, + "can_expand": True, + "children": [], + } for playlist in plex_server.playlists(): if playlist.playlistType != "audio" and platform == "sonos": continue @@ -137,35 +175,29 @@ def browse_media( # noqa: C901 continue return media_info - if media_content_id: - assert media_content_id.startswith(PLEX_URI_SCHEME) - media_content_id = media_content_id[len(PLEX_URI_SCHEME) :] - - if media_content_id and media_content_id.startswith(HUB_PREFIX): - media_content_id = media_content_id[len(HUB_PREFIX) :] - location, hub_identifier = media_content_id.split(":") - if location == "server": + if media_content_type == "hub": + if hub_location == "server": hub = next( x for x in plex_server.library.hubs() if x.hubIdentifier == hub_identifier ) - media_content_id = f"{HUB_PREFIX}server:{hub.hubIdentifier}" + media_content_id = f"server/{hub.hubIdentifier}" else: - library_section = plex_server.library.sectionByID(int(location)) + library_section = plex_server.library.sectionByID(int(hub_location)) hub = next( x for x in library_section.hubs() if x.hubIdentifier == hub_identifier ) - media_content_id = f"{HUB_PREFIX}{hub.librarySectionID}:{hub.hubIdentifier}" + media_content_id = f"{hub.librarySectionID}/{hub.hubIdentifier}" try: children_media_class = ITEM_TYPE_MEDIA_CLASS[hub.type] except KeyError as err: - raise BrowseError(f"Unknown type received: {hub.type}") from err + raise UnknownMediaType(f"Unknown type received: {hub.type}") from err payload = { "title": hub.title, "media_class": MEDIA_CLASS_DIRECTORY, - "media_content_id": PLEX_URI_SCHEME + media_content_id, - "media_content_type": hub.type, + "media_content_id": generate_plex_uri(server_id, media_content_id), + "media_content_type": "hub", "can_play": False, "can_expand": True, "children": [], @@ -180,11 +212,6 @@ def browse_media( # noqa: C901 payload["children"].append(item_payload(item)) return BrowseMedia(**payload) - if media_content_id and ":" in media_content_id: - media_content_id, special_folder = media_content_id.split(":") - else: - special_folder = None - if special_folder: if media_content_type == "server": library_or_section = plex_server.library @@ -196,7 +223,7 @@ def browse_media( # noqa: C901 try: children_media_class = ITEM_TYPE_MEDIA_CLASS[library_or_section.TYPE] except KeyError as err: - raise BrowseError( + raise UnknownMediaType( f"Unknown type received: {library_or_section.TYPE}" ) from err else: @@ -207,8 +234,9 @@ def browse_media( # noqa: C901 payload = { "title": title, "media_class": MEDIA_CLASS_DIRECTORY, - "media_content_id": PLEX_URI_SCHEME - + f"{media_content_id}:{special_folder}", + "media_content_id": generate_plex_uri( + server_id, f"{media_content_id}/{special_folder}" + ), "media_content_type": media_content_type, "can_play": False, "can_expand": True, @@ -225,11 +253,13 @@ def browse_media( # noqa: C901 return BrowseMedia(**payload) try: - if media_content_type in ("server", None): - return server_payload(plex_server, platform) + if media_content_type == "server": + return server_payload() if media_content_type == "library": - return library_payload(int(media_content_id)) + library_id = int(media_content_id) + library = plex_server.library.sectionByID(library_id) + return library_contents(library) except UnknownMediaType as err: raise BrowseError( @@ -237,7 +267,7 @@ def browse_media( # noqa: C901 ) from err if media_content_type == "playlists": - return playlists_payload(platform) + return playlists_payload() payload = { "media_type": DOMAIN, @@ -249,17 +279,61 @@ def browse_media( # noqa: C901 return response +def generate_plex_uri(server_id, media_id): + """Create a media_content_id URL for playable Plex media.""" + if isinstance(media_id, int): + media_id = str(media_id) + if isinstance(media_id, str) and not media_id.startswith("/"): + media_id = f"/{media_id}" + return str( + URL.build( + scheme=DOMAIN, + host=server_id, + path=media_id, + ) + ) + + +def root_payload(hass, is_internal, platform=None): + """Return root payload for Plex.""" + children = [] + + for server_id in hass.data[DOMAIN][SERVERS]: + children.append( + browse_media( + hass, + is_internal, + "server", + generate_plex_uri(server_id, ""), + platform=platform, + ) + ) + + if len(children) == 1: + return children[0] + + return BrowseMedia( + title="Plex", + media_class=MEDIA_CLASS_DIRECTORY, + media_content_id="", + media_content_type="plex_root", + can_play=False, + can_expand=True, + children=children, + ) + + def library_section_payload(section): """Create response payload for a single library section.""" try: children_media_class = ITEM_TYPE_MEDIA_CLASS[section.TYPE] except KeyError as err: - _LOGGER.debug("Unknown type received: %s", section.TYPE) - raise UnknownMediaType from err + raise UnknownMediaType(f"Unknown type received: {section.TYPE}") from err + server_id = section._server.machineIdentifier # pylint: disable=protected-access return BrowseMedia( title=section.title, media_class=MEDIA_CLASS_DIRECTORY, - media_content_id=PLEX_URI_SCHEME + str(section.key), + media_content_id=generate_plex_uri(server_id, section.key), media_content_type="library", can_play=False, can_expand=True, @@ -270,10 +344,11 @@ def library_section_payload(section): def special_library_payload(parent_payload, special_type): """Create response payload for special library folders.""" title = f"{special_type} ({parent_payload.title})" + special_library_id = f"{parent_payload.media_content_id}/{special_type}" return BrowseMedia( title=title, media_class=parent_payload.media_class, - media_content_id=f"{parent_payload.media_content_id}:{special_type}", + media_content_id=special_library_id, media_content_type=parent_payload.media_content_type, can_play=False, can_expand=True, @@ -281,41 +356,18 @@ def special_library_payload(parent_payload, special_type): ) -def server_payload(plex_server, platform): - """Create response payload to describe libraries of the Plex server.""" - server_info = BrowseMedia( - title=plex_server.friendly_name, - media_class=MEDIA_CLASS_DIRECTORY, - media_content_id=PLEX_URI_SCHEME + plex_server.machine_identifier, - media_content_type="server", - can_play=False, - can_expand=True, - children=[], - children_media_class=MEDIA_CLASS_DIRECTORY, - ) - if platform != "sonos": - server_info.children.append(special_library_payload(server_info, "Recommended")) - for library in plex_server.library.sections(): - if library.type == "photo": - continue - if library.type != "artist" and platform == "sonos": - continue - server_info.children.append(library_section_payload(library)) - server_info.children.append(BrowseMedia(**PLAYLISTS_BROWSE_PAYLOAD)) - return server_info - - def hub_payload(hub): """Create response payload for a hub.""" if hasattr(hub, "librarySectionID"): - media_content_id = f"{HUB_PREFIX}{hub.librarySectionID}:{hub.hubIdentifier}" + media_content_id = f"{hub.librarySectionID}/{hub.hubIdentifier}" else: - media_content_id = f"{HUB_PREFIX}server:{hub.hubIdentifier}" + media_content_id = f"server/{hub.hubIdentifier}" + server_id = hub._server.machineIdentifier # pylint: disable=protected-access payload = { "title": hub.title, "media_class": MEDIA_CLASS_DIRECTORY, - "media_content_id": PLEX_URI_SCHEME + media_content_id, - "media_content_type": hub.type, + "media_content_id": generate_plex_uri(server_id, media_content_id), + "media_content_type": "hub", "can_play": False, "can_expand": True, } @@ -324,10 +376,11 @@ def hub_payload(hub): def station_payload(station): """Create response payload for a music station.""" + server_id = station._server.machineIdentifier # pylint: disable=protected-access return BrowseMedia( title=station.title, media_class=ITEM_TYPE_MEDIA_CLASS[station.type], - media_content_id=PLEX_URI_SCHEME + station.key, + media_content_id=generate_plex_uri(server_id, station.key), media_content_type="station", can_play=True, can_expand=False, diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index d652ead7dae..6e729618022 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -2,7 +2,6 @@ from __future__ import annotations from functools import wraps -import json import logging import plexapi.exceptions @@ -46,12 +45,11 @@ from .const import ( PLEX_UPDATE_MEDIA_PLAYER_SESSION_SIGNAL, PLEX_UPDATE_MEDIA_PLAYER_SIGNAL, PLEX_UPDATE_SENSOR_SIGNAL, - PLEX_URI_SCHEME, SERVERS, TRANSIENT_DEVICE_MODELS, ) -from .errors import MediaNotFound from .media_browser import browse_media +from .services import process_plex_payload _LOGGER = logging.getLogger(__name__) @@ -483,51 +481,13 @@ class PlexMediaPlayer(MediaPlayerEntity): f"Client is not currently accepting playback controls: {self.name}" ) - if not self.plex_server.has_token: - _LOGGER.warning( - "Plex integration configured without a token, playback may fail" - ) - - if media_id.startswith(PLEX_URI_SCHEME): - media_id = media_id[len(PLEX_URI_SCHEME) :] - - if media_type == "station": - playqueue = self.plex_server.create_station_playqueue(media_id) - try: - self.device.playMedia(playqueue) - except requests.exceptions.ConnectTimeout as exc: - raise HomeAssistantError( - f"Request failed when playing on {self.name}" - ) from exc - return - - src = json.loads(media_id) - if isinstance(src, int): - src = {"plex_key": src} - - offset = 0 - - if playqueue_id := src.pop("playqueue_id", None): - try: - playqueue = self.plex_server.get_playqueue(playqueue_id) - except plexapi.exceptions.NotFound as err: - raise MediaNotFound( - f"PlayQueue '{playqueue_id}' could not be found" - ) from err - else: - shuffle = src.pop("shuffle", 0) - offset = src.pop("offset", 0) * 1000 - resume = src.pop("resume", False) - media = self.plex_server.lookup_media(media_type, **src) - - if resume and not offset: - offset = media.viewOffset - - _LOGGER.debug("Attempting to play %s on %s", media, self.name) - playqueue = self.plex_server.create_playqueue(media, shuffle=shuffle) + result = process_plex_payload( + self.hass, media_type, media_id, default_plex_server=self.plex_server + ) + _LOGGER.debug("Attempting to play %s on %s", result.media, self.name) try: - self.device.playMedia(playqueue, offset=offset) + self.device.playMedia(result.media, offset=result.offset) except requests.exceptions.ConnectTimeout as exc: raise HomeAssistantError( f"Request failed when playing on {self.name}" @@ -578,7 +538,7 @@ class PlexMediaPlayer(MediaPlayerEntity): is_internal = is_internal_request(self.hass) return await self.hass.async_add_executor_job( browse_media, - self.plex_server, + self.hass, is_internal, media_content_type, media_content_id, diff --git a/homeassistant/components/plex/models.py b/homeassistant/components/plex/models.py index 406c263dbff..0b77f450e7e 100644 --- a/homeassistant/components/plex/models.py +++ b/homeassistant/components/plex/models.py @@ -1,4 +1,5 @@ """Models to represent various Plex objects used in the integration.""" +from distutils.util import strtobool import logging from homeassistant.components.media_player.const import ( @@ -141,3 +142,35 @@ class PlexSession: thumb_url = media.url(media.art) return thumb_url + + +class PlexMediaSearchResult: + """Represents results from a Plex media media_content_id search. + + Results are used by media_player.play_media implementations. + """ + + def __init__(self, media, params=None) -> None: + """Initialize the result.""" + self.media = media + self._params = params or {} + + @property + def offset(self) -> int: + """Provide the appropriate offset based on payload contents.""" + if offset := self._params.get("offset", 0): + return offset * 1000 + resume = self._params.get("resume", False) + if isinstance(resume, str): + resume = bool(strtobool(resume)) + if resume: + return self.media.viewOffset + return 0 + + @property + def shuffle(self) -> bool: + """Return value of shuffle parameter.""" + shuffle = self._params.get("shuffle", False) + if isinstance(shuffle, str): + shuffle = bool(strtobool(shuffle)) + return shuffle diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index 280152b972f..5a6dd657942 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -4,6 +4,7 @@ import logging from plexapi.exceptions import NotFound import voluptuous as vol +from yarl import URL from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError @@ -12,11 +13,13 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( DOMAIN, PLEX_UPDATE_PLATFORMS_SIGNAL, + PLEX_URI_SCHEME, SERVERS, SERVICE_REFRESH_LIBRARY, SERVICE_SCAN_CLIENTS, ) from .errors import MediaNotFound +from .models import PlexMediaSearchResult REFRESH_LIBRARY_SCHEMA = vol.Schema( {vol.Optional("server_name"): str, vol.Required("library_name"): str} @@ -73,7 +76,7 @@ def refresh_library(hass: HomeAssistant, service_call: ServiceCall) -> None: library.update() -def get_plex_server(hass, plex_server_name=None): +def get_plex_server(hass, plex_server_name=None, plex_server_id=None): """Retrieve a configured Plex server by name.""" if DOMAIN not in hass.data: raise HomeAssistantError("Plex integration not configured") @@ -81,6 +84,9 @@ def get_plex_server(hass, plex_server_name=None): if not plex_servers: raise HomeAssistantError("No Plex servers available") + if plex_server_id: + return hass.data[DOMAIN][SERVERS][plex_server_id] + if plex_server_name: plex_server = next( (x for x in plex_servers if x.friendly_name == plex_server_name), None @@ -101,30 +107,69 @@ def get_plex_server(hass, plex_server_name=None): ) -def lookup_plex_media(hass, content_type, content_id): - """Look up Plex media for other integrations using media_player.play_media service payloads.""" - content = json.loads(content_id) +def process_plex_payload( + hass, content_type, content_id, default_plex_server=None, supports_playqueues=True +) -> PlexMediaSearchResult: + """Look up Plex media using media_player.play_media service payloads.""" + plex_server = default_plex_server + + if content_id.startswith(PLEX_URI_SCHEME + "{"): + # Handle the special payload of 'plex://{}' + content_id = content_id[len(PLEX_URI_SCHEME) :] + content = json.loads(content_id) + elif content_id.startswith(PLEX_URI_SCHEME): + # Handle standard media_browser payloads + plex_url = URL(content_id) + if plex_url.name: + if len(plex_url.parts) == 2: + # The path contains a single item, will always be a ratingKey + content = int(plex_url.name) + else: + # For "special" items like radio stations + content = plex_url.path + server_id = plex_url.host + plex_server = get_plex_server(hass, plex_server_id=server_id) + else: + # Handle legacy payloads without server_id in URL host position + content = int(plex_url.host) # type: ignore[arg-type] + else: + content = json.loads(content_id) + + if isinstance(content, dict): + if plex_server_name := content.pop("plex_server", None): + plex_server = get_plex_server(hass, plex_server_name) + + if not plex_server: + plex_server = get_plex_server(hass) + + if content_type == "station": + if not supports_playqueues: + raise HomeAssistantError("Plex stations are not supported on this device") + playqueue = plex_server.create_station_playqueue(content) + return PlexMediaSearchResult(playqueue) if isinstance(content, int): content = {"plex_key": content} content_type = DOMAIN - plex_server_name = content.pop("plex_server", None) - plex_server = get_plex_server(hass, plex_server_name) - if playqueue_id := content.pop("playqueue_id", None): + if not supports_playqueues: + raise HomeAssistantError("Plex playqueues are not supported on this device") try: playqueue = plex_server.get_playqueue(playqueue_id) except NotFound as err: raise MediaNotFound( f"PlayQueue '{playqueue_id}' could not be found" ) from err - return playqueue + return PlexMediaSearchResult(playqueue, content) - shuffle = content.pop("shuffle", 0) - media = plex_server.lookup_media(content_type, **content) + search_query = content.copy() + shuffle = search_query.pop("shuffle", 0) - if shuffle: - return plex_server.create_playqueue(media, shuffle=shuffle) + media = plex_server.lookup_media(content_type, **search_query) - return media + if supports_playqueues and (isinstance(media, list) or shuffle): + playqueue = plex_server.create_playqueue(media, shuffle=shuffle) + return PlexMediaSearchResult(playqueue, content) + + return PlexMediaSearchResult(media, content) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 4b1c9b82448..16d805a578a 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -3,7 +3,6 @@ from __future__ import annotations from asyncio import run_coroutine_threadsafe import datetime -import json import logging from typing import Any @@ -50,7 +49,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_VOLUME_SET, ) from homeassistant.components.plex.const import PLEX_URI_SCHEME -from homeassistant.components.plex.services import lookup_plex_media +from homeassistant.components.plex.services import process_plex_payload from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -567,20 +566,16 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): soco = self.coordinator.soco if media_id and media_id.startswith(PLEX_URI_SCHEME): plex_plugin = self.speaker.plex_plugin - media_id = media_id[len(PLEX_URI_SCHEME) :] - payload = json.loads(media_id) - if isinstance(payload, dict): - shuffle = payload.pop("shuffle", False) - else: - shuffle = False - media = lookup_plex_media(self.hass, media_type, json.dumps(payload)) - if shuffle: + result = process_plex_payload( + self.hass, media_type, media_id, supports_playqueues=False + ) + if result.shuffle: self.set_shuffle(True) if kwargs.get(ATTR_MEDIA_ENQUEUE): - plex_plugin.add_to_queue(media) + plex_plugin.add_to_queue(result.media) else: soco.clear_queue() - plex_plugin.add_to_queue(media) + plex_plugin.add_to_queue(result.media) soco.play_from_queue(0) return diff --git a/tests/components/plex/test_browse_media.py b/tests/components/plex/test_browse_media.py index 502084c2090..316c095160c 100644 --- a/tests/components/plex/test_browse_media.py +++ b/tests/components/plex/test_browse_media.py @@ -1,6 +1,8 @@ """Tests for Plex media browser.""" from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import Mock, patch + +from yarl import URL from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, @@ -104,6 +106,7 @@ class MockPlexStation: title = "Radio Station" radio = True type = "playlist" + _server = Mock(machineIdentifier="unique_id_123") async def test_browse_media( @@ -138,7 +141,7 @@ async def test_browse_media( assert result[ATTR_MEDIA_CONTENT_TYPE] == "server" assert ( result[ATTR_MEDIA_CONTENT_ID] - == PLEX_URI_SCHEME + DEFAULT_DATA[CONF_SERVER_IDENTIFIER] + == PLEX_URI_SCHEME + DEFAULT_DATA[CONF_SERVER_IDENTIFIER] + "/server" ) # Library Sections + Recommended + Playlists assert len(result["children"]) == len(mock_plex_server.library.sections()) + 2 @@ -162,7 +165,7 @@ async def test_browse_media( "entity_id": media_players[0], ATTR_MEDIA_CONTENT_TYPE: "server", ATTR_MEDIA_CONTENT_ID: PLEX_URI_SCHEME - + f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}", + + f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}/server/{special_keys[0]}", } ) @@ -174,13 +177,14 @@ async def test_browse_media( assert result[ATTR_MEDIA_CONTENT_TYPE] == "server" assert ( result[ATTR_MEDIA_CONTENT_ID] - == PLEX_URI_SCHEME + f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}" + == PLEX_URI_SCHEME + + f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}/server/{special_keys[0]}" ) assert len(result["children"]) == 4 # Hardcoded in fixture - assert result["children"][0]["media_content_type"] == "mixed" - assert result["children"][1]["media_content_type"] == "album" - assert result["children"][2]["media_content_type"] == "clip" - assert result["children"][3]["media_content_type"] == "playlist" + assert result["children"][0]["media_content_type"] == "hub" + assert result["children"][1]["media_content_type"] == "hub" + assert result["children"][2]["media_content_type"] == "hub" + assert result["children"][3]["media_content_type"] == "hub" # Browse into a special folder (server): Continue Watching msg_id += 1 @@ -199,7 +203,7 @@ async def test_browse_media( assert msg["type"] == TYPE_RESULT assert msg["success"] result = msg["result"] - assert result[ATTR_MEDIA_CONTENT_TYPE] == "mixed" + assert result[ATTR_MEDIA_CONTENT_TYPE] == "hub" requests_mock.get( f"{mock_plex_server.url_in_use}/hubs/sections/3?includeStations=1", @@ -216,7 +220,7 @@ async def test_browse_media( "entity_id": media_players[0], ATTR_MEDIA_CONTENT_TYPE: "library", ATTR_MEDIA_CONTENT_ID: PLEX_URI_SCHEME - + f"{library_section_id}:{special_keys[0]}", + + f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}/{library_section_id}/{special_keys[0]}", } ) @@ -228,7 +232,8 @@ async def test_browse_media( assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" assert ( result[ATTR_MEDIA_CONTENT_ID] - == PLEX_URI_SCHEME + f"{library_section_id}:{special_keys[0]}" + == PLEX_URI_SCHEME + + f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}/{library_section_id}/{special_keys[0]}" ) assert len(result["children"]) == 1 @@ -249,7 +254,7 @@ async def test_browse_media( assert msg["type"] == TYPE_RESULT assert msg["success"] result = msg["result"] - assert result[ATTR_MEDIA_CONTENT_TYPE] == "station" + assert result[ATTR_MEDIA_CONTENT_TYPE] == "hub" assert len(result["children"]) == 3 assert result["children"][0]["title"] == "Library Radio" @@ -271,7 +276,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" - result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) + result_id = int(URL(result[ATTR_MEDIA_CONTENT_ID]).name) # All items in section + Hubs assert ( len(result["children"]) @@ -305,7 +310,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "show" - result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) + result_id = int(URL(result[ATTR_MEDIA_CONTENT_ID]).name) assert result["title"] == mock_plex_server.fetch_item(result_id).title assert result["children"][0]["title"] == f"{mock_season.title} ({mock_season.year})" @@ -335,7 +340,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] assert result[ATTR_MEDIA_CONTENT_TYPE] == "season" - result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) + result_id = int(URL(result[ATTR_MEDIA_CONTENT_ID]).name) assert ( result["title"] == f"{mock_season.parentTitle} - {mock_season.title} ({mock_season.year})" @@ -360,7 +365,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] - result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) + result_id = int(URL(result[ATTR_MEDIA_CONTENT_ID]).name) assert result[ATTR_MEDIA_CONTENT_TYPE] == "library" assert result["title"] == "Music" @@ -390,7 +395,7 @@ async def test_browse_media( assert mock_fetch.called assert msg["success"] result = msg["result"] - result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) + result_id = int(URL(result[ATTR_MEDIA_CONTENT_ID]).name) assert result[ATTR_MEDIA_CONTENT_TYPE] == "artist" assert result["title"] == mock_artist.title assert result["children"][0]["title"] == "Radio Station" @@ -411,7 +416,7 @@ async def test_browse_media( assert msg["success"] result = msg["result"] - result_id = int(result[ATTR_MEDIA_CONTENT_ID][len(PLEX_URI_SCHEME) :]) + result_id = int(URL(result[ATTR_MEDIA_CONTENT_ID]).name) assert result[ATTR_MEDIA_CONTENT_TYPE] == "album" assert ( result["title"] diff --git a/tests/components/plex/test_playback.py b/tests/components/plex/test_playback.py index e67249ea375..e90a64a5fe1 100644 --- a/tests/components/plex/test_playback.py +++ b/tests/components/plex/test_playback.py @@ -1,6 +1,6 @@ """Tests for Plex player playback methods/services.""" from http import HTTPStatus -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -11,14 +11,19 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_MOVIE, SERVICE_PLAY_MEDIA, ) +from homeassistant.components.plex.const import CONF_SERVER_IDENTIFIER, PLEX_URI_SCHEME from homeassistant.const import ATTR_ENTITY_ID from homeassistant.exceptions import HomeAssistantError +from .const import DEFAULT_DATA, PLEX_DIRECT_URL + class MockPlexMedia: """Minimal mock of plexapi media object.""" key = "key" + viewOffset = 333 + _server = Mock(_baseurl=PLEX_DIRECT_URL) def __init__(self, title, mediatype): """Initialize the instance.""" @@ -51,7 +56,9 @@ async def test_media_player_playback( media_player = "media_player.plex_plex_web_chrome" requests_mock.post("/playqueues", text=playqueue_created) - requests_mock.get("/player/playback/playMedia", status_code=HTTPStatus.OK) + playmedia_mock = requests_mock.get( + "/player/playback/playMedia", status_code=HTTPStatus.OK + ) # Test media lookup failure payload = '{"library_name": "Movies", "title": "Movie 1" }' @@ -67,6 +74,7 @@ async def test_media_player_playback( }, True, ) + assert not playmedia_mock.called assert f"No {MEDIA_TYPE_MOVIE} results in 'Movies' for" in str(excinfo.value) movie1 = MockPlexMedia("Movie", "movie") @@ -86,12 +94,57 @@ async def test_media_player_playback( }, True, ) + assert playmedia_mock.called + + # Test movie success with resume + playmedia_mock.reset() + with patch("plexapi.library.LibrarySection.search", return_value=movies): + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MOVIE, + ATTR_MEDIA_CONTENT_ID: '{"library_name": "Movies", "title": "Movie 1", "resume": true}', + }, + True, + ) + assert playmedia_mock.called + assert playmedia_mock.last_request.qs["offset"][0] == str(movie1.viewOffset) + + # Test movie success with media browser URL + playmedia_mock.reset() + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MOVIE, + ATTR_MEDIA_CONTENT_ID: PLEX_URI_SCHEME + + f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}/1", + }, + True, + ) + assert playmedia_mock.called + + # Test movie success with legacy media browser URL + playmedia_mock.reset() + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MOVIE, + ATTR_MEDIA_CONTENT_ID: PLEX_URI_SCHEME + "1", + }, + True, + ) + assert playmedia_mock.called # Test multiple choices with exact match + playmedia_mock.reset() movies = [movie1, movie2] - with patch("plexapi.library.LibrarySection.search", return_value=movies), patch( - "homeassistant.components.plex.server.PlexServer.create_playqueue" - ) as mock_create_playqueue: + with patch("plexapi.library.LibrarySection.search", return_value=movies): assert await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -102,9 +155,10 @@ async def test_media_player_playback( }, True, ) - assert mock_create_playqueue.call_args.args == (movie1,) + assert playmedia_mock.called # Test multiple choices without exact match + playmedia_mock.reset() movies = [movie2, movie3] with pytest.raises(HomeAssistantError) as excinfo: payload = '{"library_name": "Movies", "title": "Movie" }' @@ -119,6 +173,7 @@ async def test_media_player_playback( }, True, ) + assert not playmedia_mock.called assert "Multiple matches, make content_id more specific" in str(excinfo.value) # Test multiple choices with allow_multiple @@ -137,3 +192,20 @@ async def test_media_player_playback( True, ) assert mock_create_playqueue.call_args.args == (movies,) + assert playmedia_mock.called + + # Test radio station + playmedia_mock.reset() + radio_id = "/library/sections/3/stations/1" + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: "station", + ATTR_MEDIA_CONTENT_ID: PLEX_URI_SCHEME + + f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}/{radio_id}", + }, + True, + ) + assert playmedia_mock.called diff --git a/tests/components/plex/test_services.py b/tests/components/plex/test_services.py index ddc4ed58ba8..f579fd90f90 100644 --- a/tests/components/plex/test_services.py +++ b/tests/components/plex/test_services.py @@ -13,10 +13,11 @@ from homeassistant.components.plex.const import ( CONF_SERVER_IDENTIFIER, DOMAIN, PLEX_SERVER_CONFIG, + PLEX_URI_SCHEME, SERVICE_REFRESH_LIBRARY, SERVICE_SCAN_CLIENTS, ) -from homeassistant.components.plex.services import lookup_plex_media +from homeassistant.components.plex.services import process_plex_payload from homeassistant.const import CONF_URL from homeassistant.exceptions import HomeAssistantError @@ -121,19 +122,25 @@ async def test_lookup_media_for_other_integrations( playqueue_created, ): """Test media lookup for media_player.play_media calls from cast/sonos.""" - CONTENT_ID = '{"library_name": "Music", "artist_name": "Artist"}' - CONTENT_ID_KEY = "100" - CONTENT_ID_BAD_MEDIA = '{"library_name": "Music", "artist_name": "Not an Artist"}' - CONTENT_ID_PLAYQUEUE = '{"playqueue_id": 1234}' - CONTENT_ID_BAD_PLAYQUEUE = '{"playqueue_id": 1235}' - CONTENT_ID_SERVER = '{"plex_server": "Plex Server 1", "library_name": "Music", "artist_name": "Artist"}' + CONTENT_ID = PLEX_URI_SCHEME + '{"library_name": "Music", "artist_name": "Artist"}' + CONTENT_ID_KEY = PLEX_URI_SCHEME + "100" + CONTENT_ID_BAD_MEDIA = ( + PLEX_URI_SCHEME + '{"library_name": "Music", "artist_name": "Not an Artist"}' + ) + CONTENT_ID_PLAYQUEUE = PLEX_URI_SCHEME + '{"playqueue_id": 1234}' + CONTENT_ID_BAD_PLAYQUEUE = PLEX_URI_SCHEME + '{"playqueue_id": 1235}' + CONTENT_ID_SERVER = ( + PLEX_URI_SCHEME + + '{"plex_server": "Plex Server 1", "library_name": "Music", "artist_name": "Artist"}' + ) CONTENT_ID_SHUFFLE = ( - '{"library_name": "Music", "artist_name": "Artist", "shuffle": 1}' + PLEX_URI_SCHEME + + '{"library_name": "Music", "artist_name": "Artist", "shuffle": 1}' ) # Test with no Plex integration available with pytest.raises(HomeAssistantError) as excinfo: - lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID) + process_plex_payload(hass, MEDIA_TYPE_MUSIC, CONTENT_ID) assert "Plex integration not configured" in str(excinfo.value) with patch( @@ -145,45 +152,61 @@ async def test_lookup_media_for_other_integrations( # Test with no Plex servers available with pytest.raises(HomeAssistantError) as excinfo: - lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID) + process_plex_payload(hass, MEDIA_TYPE_MUSIC, CONTENT_ID) assert "No Plex servers available" in str(excinfo.value) # Complete setup of a Plex server await hass.config_entries.async_unload(entry.entry_id) await setup_plex_server() - # Test lookup success - result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID) - assert isinstance(result, plexapi.audio.Artist) + # Test lookup success without playqueue + result = process_plex_payload( + hass, MEDIA_TYPE_MUSIC, CONTENT_ID, supports_playqueues=False + ) + assert isinstance(result.media, plexapi.audio.Artist) + assert not result.shuffle - # Test media key payload - result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_KEY) - assert isinstance(result, plexapi.audio.Track) + # Test media key payload without playqueue + result = process_plex_payload( + hass, MEDIA_TYPE_MUSIC, CONTENT_ID_KEY, supports_playqueues=False + ) + assert isinstance(result.media, plexapi.audio.Track) + assert not result.shuffle - # Test with specified server - result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_SERVER) - assert isinstance(result, plexapi.audio.Artist) + # Test with specified server without playqueue + result = process_plex_payload( + hass, MEDIA_TYPE_MUSIC, CONTENT_ID_SERVER, supports_playqueues=False + ) + assert isinstance(result.media, plexapi.audio.Artist) + assert not result.shuffle + + # Test shuffle without playqueue + result = process_plex_payload( + hass, MEDIA_TYPE_MUSIC, CONTENT_ID_SHUFFLE, supports_playqueues=False + ) + assert isinstance(result.media, plexapi.audio.Artist) + assert result.shuffle # Test with media not found with patch("plexapi.library.LibrarySection.search", return_value=None): with pytest.raises(HomeAssistantError) as excinfo: - lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_BAD_MEDIA) + process_plex_payload(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_BAD_MEDIA) assert f"No {MEDIA_TYPE_MUSIC} results in 'Music' for" in str(excinfo.value) # Test with playqueue requests_mock.get("https://1.2.3.4:32400/playQueues/1234", text=playqueue_1234) - result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_PLAYQUEUE) - assert isinstance(result, plexapi.playqueue.PlayQueue) + result = process_plex_payload(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_PLAYQUEUE) + assert isinstance(result.media, plexapi.playqueue.PlayQueue) # Test with invalid playqueue requests_mock.get( "https://1.2.3.4:32400/playQueues/1235", status_code=HTTPStatus.NOT_FOUND ) with pytest.raises(HomeAssistantError) as excinfo: - lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_BAD_PLAYQUEUE) + process_plex_payload(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_BAD_PLAYQUEUE) assert "PlayQueue '1235' could not be found" in str(excinfo.value) # Test playqueue is created with shuffle requests_mock.post("/playqueues", text=playqueue_created) - result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_SHUFFLE) - assert isinstance(result, plexapi.playqueue.PlayQueue) + result = process_plex_payload(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_SHUFFLE) + assert isinstance(result.media, plexapi.playqueue.PlayQueue) diff --git a/tests/components/sonos/test_plex_playback.py b/tests/components/sonos/test_plex_playback.py index 4c7c6f4f94a..27a9f652c95 100644 --- a/tests/components/sonos/test_plex_playback.py +++ b/tests/components/sonos/test_plex_playback.py @@ -1,5 +1,6 @@ """Tests for the Sonos Media Player platform.""" -from unittest.mock import patch +import json +from unittest.mock import Mock, patch import pytest @@ -10,23 +11,25 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_MUSIC, SERVICE_PLAY_MEDIA, ) -from homeassistant.components.plex.const import PLEX_URI_SCHEME +from homeassistant.components.plex.const import DOMAIN as PLEX_DOMAIN, PLEX_URI_SCHEME from homeassistant.const import ATTR_ENTITY_ID from homeassistant.exceptions import HomeAssistantError async def test_plex_play_media(hass, async_autosetup_sonos): """Test playing media via the Plex integration.""" + mock_plex_server = Mock() + mock_lookup = mock_plex_server.lookup_media + media_player = "media_player.zone_a" media_content_id = ( '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}' ) with patch( - "homeassistant.components.sonos.media_player.lookup_plex_media" - ) as mock_lookup, patch( - "soco.plugins.plex.PlexPlugin.add_to_queue" - ) as mock_add_to_queue, patch( + "homeassistant.components.plex.services.get_plex_server", + return_value=mock_plex_server, + ), patch("soco.plugins.plex.PlexPlugin.add_to_queue") as mock_add_to_queue, patch( "homeassistant.components.sonos.media_player.SonosMediaPlayerEntity.set_shuffle" ) as mock_shuffle: # Test successful Plex service call @@ -44,8 +47,8 @@ async def test_plex_play_media(hass, async_autosetup_sonos): assert len(mock_lookup.mock_calls) == 1 assert len(mock_add_to_queue.mock_calls) == 1 assert not mock_shuffle.called - assert mock_lookup.mock_calls[0][1][1] == MEDIA_TYPE_MUSIC - assert mock_lookup.mock_calls[0][1][2] == media_content_id + assert mock_lookup.mock_calls[0][1][0] == MEDIA_TYPE_MUSIC + assert mock_lookup.mock_calls[0][2] == json.loads(media_content_id) # Test handling shuffle in payload mock_lookup.reset_mock() @@ -66,8 +69,8 @@ async def test_plex_play_media(hass, async_autosetup_sonos): assert mock_shuffle.called assert len(mock_lookup.mock_calls) == 1 assert len(mock_add_to_queue.mock_calls) == 1 - assert mock_lookup.mock_calls[0][1][1] == MEDIA_TYPE_MUSIC - assert mock_lookup.mock_calls[0][1][2] == media_content_id + assert mock_lookup.mock_calls[0][1][0] == MEDIA_TYPE_MUSIC + assert mock_lookup.mock_calls[0][2] == json.loads(media_content_id) # Test failed Plex service call mock_lookup.reset_mock() @@ -87,3 +90,32 @@ async def test_plex_play_media(hass, async_autosetup_sonos): ) assert mock_lookup.called assert not mock_add_to_queue.called + + # Test new media browser payload format + mock_lookup.reset_mock() + mock_lookup.side_effect = None + mock_add_to_queue.reset_mock() + + server_id = "unique_id_123" + plex_item_key = 300 + + with patch( + "homeassistant.components.plex.services.get_plex_server", + return_value=mock_plex_server, + ): + assert await hass.services.async_call( + MP_DOMAIN, + SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: media_player, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_CONTENT_ID: f"{PLEX_URI_SCHEME}{server_id}/{plex_item_key}?shuffle=1", + }, + blocking=True, + ) + + assert len(mock_lookup.mock_calls) == 1 + assert len(mock_add_to_queue.mock_calls) == 1 + assert mock_shuffle.called + assert mock_lookup.mock_calls[0][1][0] == PLEX_DOMAIN + assert mock_lookup.mock_calls[0][2] == {"plex_key": plex_item_key}