diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 7ecdebbb482..9995a52af36 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -529,7 +529,7 @@ class CastDevice(MediaPlayerEntity): # Handle plex elif media_id and media_id.startswith(PLEX_URI_SCHEME): media_id = media_id[len(PLEX_URI_SCHEME) :] - media, _ = lookup_plex_media(self.hass, media_type, media_id) + media = lookup_plex_media(self.hass, media_type, media_id) if media is None: return controller = PlexController() diff --git a/homeassistant/components/plex/services.py b/homeassistant/components/plex/services.py index 24cd26e0651..11d40dbab75 100644 --- a/homeassistant/components/plex/services.py +++ b/homeassistant/components/plex/services.py @@ -2,7 +2,7 @@ import json import logging -from plexapi.exceptions import BadRequest, NotFound +from plexapi.exceptions import NotFound import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall @@ -115,33 +115,14 @@ def lookup_plex_media(hass, content_type, content_id): raise HomeAssistantError( f"PlayQueue '{playqueue_id}' could not be found" ) from err - else: - shuffle = content.pop("shuffle", 0) - media = plex_server.lookup_media(content_type, **content) - if media is None: - raise HomeAssistantError( - f"Plex media not found using payload: '{content_id}'" - ) - playqueue = plex_server.create_playqueue(media, shuffle=shuffle) + return playqueue - return (playqueue, plex_server) + shuffle = content.pop("shuffle", 0) + media = plex_server.lookup_media(content_type, **content) + if media is None: + raise HomeAssistantError(f"Plex media not found using payload: '{content_id}'") + if shuffle: + return plex_server.create_playqueue(media, shuffle=shuffle) -def play_on_sonos(hass, content_type, content_id, speaker_name): - """Play music on a connected Sonos speaker using Plex APIs. - - Called by Sonos 'media_player.play_media' service. - """ - media, plex_server = lookup_plex_media(hass, content_type, content_id) - try: - sonos_speaker = plex_server.account.sonos_speaker(speaker_name) - except BadRequest as exc: - raise HomeAssistantError( - "Sonos speakers not linked to Plex account, complete this step in the Plex app" - ) from exc - - if sonos_speaker is None: - message = f"Sonos speaker '{speaker_name}' is not associated with '{plex_server.friendly_name}'" - _LOGGER.error(message) - raise HomeAssistantError(message) - sonos_speaker.playMedia(media) + return media diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 07fcfb9a3f4..34574c95b8f 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -3,7 +3,7 @@ from __future__ import annotations from asyncio import run_coroutine_threadsafe import datetime -from datetime import timedelta +import json import logging from typing import Any from urllib.parse import quote @@ -48,7 +48,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 play_on_sonos +from homeassistant.components.plex.services import lookup_plex_media 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 @@ -543,8 +543,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) :] - play_on_sonos(self.hass, media_type, media_id, self.name) # type: ignore[no-untyped-call] + payload = json.loads(media_id) + shuffle = payload.pop("shuffle", None) + media = lookup_plex_media(self.hass, media_type, json.dumps(payload)) + if not kwargs.get(ATTR_MEDIA_ENQUEUE): + soco.clear_queue() + if shuffle: + self.set_shuffle(True) + plex_plugin.play_now(media) return share_link = self.speaker.share_link @@ -562,7 +570,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_id = async_sign_path( self.hass, quote(media_id), - timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME), + datetime.timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME), ) # prepend external URL diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 2f1030fdca8..4de0638d10c 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -17,6 +17,7 @@ from soco.data_structures import DidlAudioBroadcast, DidlPlaylistContainer from soco.events_base import Event as SonosEvent, SubscriptionBase from soco.exceptions import SoCoException, SoCoSlaveException, SoCoUPnPException from soco.music_library import MusicLibrary +from soco.plugins.plex import PlexPlugin from soco.plugins.sharelink import ShareLinkPlugin from soco.snapshot import Snapshot @@ -155,6 +156,7 @@ class SonosSpeaker: self.soco = soco self.household_id: str = soco.household_id self.media = SonosMedia(soco) + self._plex_plugin: PlexPlugin | None = None self._share_link_plugin: ShareLinkPlugin | None = None self.available = True @@ -312,6 +314,13 @@ class SonosSpeaker: """Return true if player is a coordinator.""" return self.coordinator is None + @property + def plex_plugin(self) -> PlexPlugin: + """Cache the PlexPlugin instance for this speaker.""" + if not self._plex_plugin: + self._plex_plugin = PlexPlugin(self.soco) + return self._plex_plugin + @property def share_link(self) -> ShareLinkPlugin: """Cache the ShareLinkPlugin instance for this speaker.""" diff --git a/tests/components/cast/conftest.py b/tests/components/cast/conftest.py index 3b96f378906..2d0114c0110 100644 --- a/tests/components/cast/conftest.py +++ b/tests/components/cast/conftest.py @@ -26,6 +26,12 @@ def mz_mock(): return MagicMock(spec_set=pychromecast.controllers.multizone.MultizoneManager) +@pytest.fixture() +def plex_mock(): + """Mock pychromecast PlexController.""" + return MagicMock(spec_set=pychromecast.controllers.plex.PlexController) + + @pytest.fixture() def quick_play_mock(): """Mock pychromecast quick_play.""" @@ -45,6 +51,7 @@ def cast_mock( castbrowser_mock, get_chromecast_mock, get_multizone_status_mock, + plex_mock, ): """Mock pychromecast.""" ignore_cec_orig = list(pychromecast.IGNORE_CEC) @@ -58,6 +65,9 @@ def cast_mock( ), patch( "homeassistant.components.cast.media_player.MultizoneManager", return_value=mz_mock, + ), patch( + "homeassistant.components.cast.media_player.PlexController", + return_value=plex_mock, ), patch( "homeassistant.components.cast.media_player.zeroconf.async_get_instance", AsyncMock(), diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 9532f56309a..5e14b5e7bd6 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -844,6 +844,54 @@ async def test_entity_play_media_cast(hass: HomeAssistant, quick_play_mock): ) +async def test_entity_play_media_plex(hass: HomeAssistant, plex_mock): + """Test playing media.""" + entity_id = "media_player.speaker" + + info = get_fake_chromecast_info() + + chromecast, _ = await async_setup_media_player_cast(hass, info) + _, conn_status_cb, _ = get_status_callbacks(chromecast) + + connection_status = MagicMock() + connection_status.status = "CONNECTED" + conn_status_cb(connection_status) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.cast.media_player.lookup_plex_media", + return_value=None, + ): + await hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: entity_id, + media_player.ATTR_MEDIA_CONTENT_TYPE: "music", + media_player.ATTR_MEDIA_CONTENT_ID: 'plex://{"library_name": "Music", "artist.title": "Not an Artist"}', + }, + blocking=True, + ) + assert not plex_mock.play_media.called + + mock_plex_media = MagicMock() + with patch( + "homeassistant.components.cast.media_player.lookup_plex_media", + return_value=mock_plex_media, + ): + await hass.services.async_call( + media_player.DOMAIN, + media_player.SERVICE_PLAY_MEDIA, + { + ATTR_ENTITY_ID: entity_id, + media_player.ATTR_MEDIA_CONTENT_TYPE: "music", + media_player.ATTR_MEDIA_CONTENT_ID: 'plex://{"library_name": "Music", "artist.title": "Artist"}', + }, + blocking=True, + ) + plex_mock.play_media.assert_called_once_with(mock_plex_media) + + async def test_entity_play_media_cast_invalid(hass, caplog, quick_play_mock): """Test playing media.""" entity_id = "media_player.speaker" diff --git a/tests/components/plex/test_services.py b/tests/components/plex/test_services.py index 161755c9703..49fdd48f662 100644 --- a/tests/components/plex/test_services.py +++ b/tests/components/plex/test_services.py @@ -2,7 +2,9 @@ from http import HTTPStatus from unittest.mock import patch +import plexapi.audio from plexapi.exceptions import NotFound +import plexapi.playqueue import pytest from homeassistant.components.media_player.const import MEDIA_TYPE_MUSIC @@ -14,7 +16,7 @@ from homeassistant.components.plex.const import ( SERVICE_REFRESH_LIBRARY, SERVICE_SCAN_CLIENTS, ) -from homeassistant.components.plex.services import play_on_sonos +from homeassistant.components.plex.services import lookup_plex_media from homeassistant.const import CONF_URL from homeassistant.exceptions import HomeAssistantError @@ -110,32 +112,28 @@ async def test_scan_clients(hass, mock_plex_server): ) -async def test_sonos_play_media( +async def test_lookup_media_for_other_integrations( hass, entry, setup_plex_server, requests_mock, - empty_payload, playqueue_1234, playqueue_created, - plextv_account, - sonos_resources, ): - """Test playback from a Sonos media_player.play_media call.""" - media_content_id = ( - '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}' - ) - sonos_speaker_name = "Zone A" - - requests_mock.get("https://plex.tv/users/account", text=plextv_account) - requests_mock.post("/playqueues", text=playqueue_created) - playback_mock = requests_mock.get( - "/player/playback/playMedia", status_code=HTTPStatus.OK + """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_SHUFFLE = ( + '{"library_name": "Music", "artist_name": "Artist", "shuffle": 1}' ) # Test with no Plex integration available with pytest.raises(HomeAssistantError) as excinfo: - play_on_sonos(hass, MEDIA_TYPE_MUSIC, media_content_id, sonos_speaker_name) + lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID) assert "Plex integration not configured" in str(excinfo.value) with patch( @@ -147,68 +145,45 @@ async def test_sonos_play_media( # Test with no Plex servers available with pytest.raises(HomeAssistantError) as excinfo: - play_on_sonos(hass, MEDIA_TYPE_MUSIC, media_content_id, sonos_speaker_name) + lookup_plex_media(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) - mock_plex_server = await setup_plex_server() + await setup_plex_server() - # Test with unlinked Plex/Sonos accounts - requests_mock.get("https://sonos.plex.tv/resources", status_code=403) - with pytest.raises(HomeAssistantError) as excinfo: - play_on_sonos(hass, MEDIA_TYPE_MUSIC, media_content_id, sonos_speaker_name) - assert "Sonos speakers not linked to Plex account" in str(excinfo.value) - assert playback_mock.call_count == 0 + # Test lookup success + result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID) + assert isinstance(result, plexapi.audio.Artist) - # Test with no speakers available - requests_mock.get("https://sonos.plex.tv/resources", text=empty_payload) - with pytest.raises(HomeAssistantError) as excinfo: - play_on_sonos(hass, MEDIA_TYPE_MUSIC, media_content_id, sonos_speaker_name) - assert f"Sonos speaker '{sonos_speaker_name}' is not associated with" in str( - excinfo.value - ) - assert playback_mock.call_count == 0 + # Test media key payload + result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_KEY) + assert isinstance(result, plexapi.audio.Track) - # Test with speakers available - requests_mock.get("https://sonos.plex.tv/resources", text=sonos_resources) - with patch.object(mock_plex_server.account, "_sonos_cache_timestamp", 0): - play_on_sonos(hass, MEDIA_TYPE_MUSIC, media_content_id, sonos_speaker_name) - assert playback_mock.call_count == 1 + # Test with specified server + result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_SERVER) + assert isinstance(result, plexapi.audio.Artist) - # Test with speakers available and media key payload - play_on_sonos(hass, MEDIA_TYPE_MUSIC, "100", sonos_speaker_name) - assert playback_mock.call_count == 2 - - # Test with speakers available and Plex server specified - content_id_with_server = '{"plex_server": "Plex Server 1", "library_name": "Music", "artist_name": "Artist", "album_name": "Album"}' - play_on_sonos(hass, MEDIA_TYPE_MUSIC, content_id_with_server, sonos_speaker_name) - assert playback_mock.call_count == 3 - - # Test with speakers available but media not found - content_id_bad_media = '{"library_name": "Music", "artist_name": "Not an Artist"}' + # Test with media not found with patch("plexapi.library.LibrarySection.search", return_value=None): with pytest.raises(HomeAssistantError) as excinfo: - play_on_sonos( - hass, MEDIA_TYPE_MUSIC, content_id_bad_media, sonos_speaker_name - ) + lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_BAD_MEDIA) assert "Plex media not found" in str(excinfo.value) - assert playback_mock.call_count == 3 - # Test with speakers available and playqueue + # Test with playqueue requests_mock.get("https://1.2.3.4:32400/playQueues/1234", text=playqueue_1234) - content_id_with_playqueue = '{"playqueue_id": 1234}' - play_on_sonos(hass, MEDIA_TYPE_MUSIC, content_id_with_playqueue, sonos_speaker_name) - assert playback_mock.call_count == 4 + result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_PLAYQUEUE) + assert isinstance(result, plexapi.playqueue.PlayQueue) - # Test with speakers available and invalid playqueue + # Test with invalid playqueue requests_mock.get( "https://1.2.3.4:32400/playQueues/1235", status_code=HTTPStatus.NOT_FOUND ) - content_id_with_playqueue = '{"playqueue_id": 1235}' with pytest.raises(HomeAssistantError) as excinfo: - play_on_sonos( - hass, MEDIA_TYPE_MUSIC, content_id_with_playqueue, sonos_speaker_name - ) + lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_BAD_PLAYQUEUE) assert "PlayQueue '1235' could not be found" in str(excinfo.value) - assert playback_mock.call_count == 4 + + # 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) diff --git a/tests/components/sonos/test_plex_playback.py b/tests/components/sonos/test_plex_playback.py index d9f809e8395..eeaa5544222 100644 --- a/tests/components/sonos/test_plex_playback.py +++ b/tests/components/sonos/test_plex_playback.py @@ -23,8 +23,12 @@ async def test_plex_play_media(hass, async_autosetup_sonos): ) with patch( - "homeassistant.components.sonos.media_player.play_on_sonos" - ) as mock_play: + "homeassistant.components.sonos.media_player.lookup_plex_media" + ) as mock_lookup, patch( + "soco.plugins.plex.PlexPlugin.play_now" + ) as mock_play_now, patch( + "homeassistant.components.sonos.media_player.SonosMediaPlayerEntity.set_shuffle" + ) as mock_shuffle: # Test successful Plex service call assert await hass.services.async_call( MP_DOMAIN, @@ -37,14 +41,38 @@ async def test_plex_play_media(hass, async_autosetup_sonos): blocking=True, ) - assert len(mock_play.mock_calls) == 1 - assert mock_play.mock_calls[0][1][1] == MEDIA_TYPE_MUSIC - assert mock_play.mock_calls[0][1][2] == media_content_id - assert mock_play.mock_calls[0][1][3] == "Zone A" + assert len(mock_lookup.mock_calls) == 1 + assert len(mock_play_now.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 + + # Test handling shuffle in payload + mock_lookup.reset_mock() + mock_play_now.reset_mock() + shuffle_media_content_id = '{"library_name": "Music", "artist_name": "Artist", "album_name": "Album", "shuffle": 1}' + + 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}{shuffle_media_content_id}", + }, + blocking=True, + ) + + assert mock_shuffle.called + assert len(mock_lookup.mock_calls) == 1 + assert len(mock_play_now.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 # Test failed Plex service call - mock_play.reset_mock() - mock_play.side_effect = HomeAssistantError + mock_lookup.reset_mock() + mock_lookup.side_effect = HomeAssistantError + mock_play_now.reset_mock() with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -57,4 +85,5 @@ async def test_plex_play_media(hass, async_autosetup_sonos): }, blocking=True, ) - assert mock_play.called + assert mock_lookup.called + assert not mock_play_now.called