Use local Sonos API for Plex music playback (#63357)

This commit is contained in:
jjlawren 2022-01-25 23:10:11 -06:00 committed by GitHub
parent cf7148c3f8
commit cdad1a9f27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 164 additions and 104 deletions

View file

@ -529,7 +529,7 @@ class CastDevice(MediaPlayerEntity):
# Handle plex # Handle plex
elif media_id and media_id.startswith(PLEX_URI_SCHEME): elif media_id and media_id.startswith(PLEX_URI_SCHEME):
media_id = media_id[len(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: if media is None:
return return
controller = PlexController() controller = PlexController()

View file

@ -2,7 +2,7 @@
import json import json
import logging import logging
from plexapi.exceptions import BadRequest, NotFound from plexapi.exceptions import NotFound
import voluptuous as vol import voluptuous as vol
from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.core import HomeAssistant, ServiceCall
@ -115,33 +115,14 @@ def lookup_plex_media(hass, content_type, content_id):
raise HomeAssistantError( raise HomeAssistantError(
f"PlayQueue '{playqueue_id}' could not be found" f"PlayQueue '{playqueue_id}' could not be found"
) from err ) from err
else: return playqueue
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, 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): return media
"""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)

View file

@ -3,7 +3,7 @@ from __future__ import annotations
from asyncio import run_coroutine_threadsafe from asyncio import run_coroutine_threadsafe
import datetime import datetime
from datetime import timedelta import json
import logging import logging
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import quote
@ -48,7 +48,7 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_SET, SUPPORT_VOLUME_SET,
) )
from homeassistant.components.plex.const import PLEX_URI_SCHEME 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.config_entries import ConfigEntry
from homeassistant.const import ATTR_TIME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING from homeassistant.const import ATTR_TIME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING
from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.core import HomeAssistant, ServiceCall, callback
@ -543,8 +543,16 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
soco = self.coordinator.soco soco = self.coordinator.soco
if media_id and media_id.startswith(PLEX_URI_SCHEME): if media_id and media_id.startswith(PLEX_URI_SCHEME):
plex_plugin = self.speaker.plex_plugin
media_id = media_id[len(PLEX_URI_SCHEME) :] 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 return
share_link = self.speaker.share_link share_link = self.speaker.share_link
@ -562,7 +570,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
media_id = async_sign_path( media_id = async_sign_path(
self.hass, self.hass,
quote(media_id), quote(media_id),
timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME), datetime.timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME),
) )
# prepend external URL # prepend external URL

View file

@ -17,6 +17,7 @@ from soco.data_structures import DidlAudioBroadcast, DidlPlaylistContainer
from soco.events_base import Event as SonosEvent, SubscriptionBase from soco.events_base import Event as SonosEvent, SubscriptionBase
from soco.exceptions import SoCoException, SoCoSlaveException, SoCoUPnPException from soco.exceptions import SoCoException, SoCoSlaveException, SoCoUPnPException
from soco.music_library import MusicLibrary from soco.music_library import MusicLibrary
from soco.plugins.plex import PlexPlugin
from soco.plugins.sharelink import ShareLinkPlugin from soco.plugins.sharelink import ShareLinkPlugin
from soco.snapshot import Snapshot from soco.snapshot import Snapshot
@ -155,6 +156,7 @@ class SonosSpeaker:
self.soco = soco self.soco = soco
self.household_id: str = soco.household_id self.household_id: str = soco.household_id
self.media = SonosMedia(soco) self.media = SonosMedia(soco)
self._plex_plugin: PlexPlugin | None = None
self._share_link_plugin: ShareLinkPlugin | None = None self._share_link_plugin: ShareLinkPlugin | None = None
self.available = True self.available = True
@ -312,6 +314,13 @@ class SonosSpeaker:
"""Return true if player is a coordinator.""" """Return true if player is a coordinator."""
return self.coordinator is None 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 @property
def share_link(self) -> ShareLinkPlugin: def share_link(self) -> ShareLinkPlugin:
"""Cache the ShareLinkPlugin instance for this speaker.""" """Cache the ShareLinkPlugin instance for this speaker."""

View file

@ -26,6 +26,12 @@ def mz_mock():
return MagicMock(spec_set=pychromecast.controllers.multizone.MultizoneManager) 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() @pytest.fixture()
def quick_play_mock(): def quick_play_mock():
"""Mock pychromecast quick_play.""" """Mock pychromecast quick_play."""
@ -45,6 +51,7 @@ def cast_mock(
castbrowser_mock, castbrowser_mock,
get_chromecast_mock, get_chromecast_mock,
get_multizone_status_mock, get_multizone_status_mock,
plex_mock,
): ):
"""Mock pychromecast.""" """Mock pychromecast."""
ignore_cec_orig = list(pychromecast.IGNORE_CEC) ignore_cec_orig = list(pychromecast.IGNORE_CEC)
@ -58,6 +65,9 @@ def cast_mock(
), patch( ), patch(
"homeassistant.components.cast.media_player.MultizoneManager", "homeassistant.components.cast.media_player.MultizoneManager",
return_value=mz_mock, return_value=mz_mock,
), patch(
"homeassistant.components.cast.media_player.PlexController",
return_value=plex_mock,
), patch( ), patch(
"homeassistant.components.cast.media_player.zeroconf.async_get_instance", "homeassistant.components.cast.media_player.zeroconf.async_get_instance",
AsyncMock(), AsyncMock(),

View file

@ -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): async def test_entity_play_media_cast_invalid(hass, caplog, quick_play_mock):
"""Test playing media.""" """Test playing media."""
entity_id = "media_player.speaker" entity_id = "media_player.speaker"

View file

@ -2,7 +2,9 @@
from http import HTTPStatus from http import HTTPStatus
from unittest.mock import patch from unittest.mock import patch
import plexapi.audio
from plexapi.exceptions import NotFound from plexapi.exceptions import NotFound
import plexapi.playqueue
import pytest import pytest
from homeassistant.components.media_player.const import MEDIA_TYPE_MUSIC from homeassistant.components.media_player.const import MEDIA_TYPE_MUSIC
@ -14,7 +16,7 @@ from homeassistant.components.plex.const import (
SERVICE_REFRESH_LIBRARY, SERVICE_REFRESH_LIBRARY,
SERVICE_SCAN_CLIENTS, 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.const import CONF_URL
from homeassistant.exceptions import HomeAssistantError 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, hass,
entry, entry,
setup_plex_server, setup_plex_server,
requests_mock, requests_mock,
empty_payload,
playqueue_1234, playqueue_1234,
playqueue_created, playqueue_created,
plextv_account,
sonos_resources,
): ):
"""Test playback from a Sonos media_player.play_media call.""" """Test media lookup for media_player.play_media calls from cast/sonos."""
media_content_id = ( CONTENT_ID = '{"library_name": "Music", "artist_name": "Artist"}'
'{"library_name": "Music", "artist_name": "Artist", "album_name": "Album"}' CONTENT_ID_KEY = "100"
) CONTENT_ID_BAD_MEDIA = '{"library_name": "Music", "artist_name": "Not an Artist"}'
sonos_speaker_name = "Zone A" CONTENT_ID_PLAYQUEUE = '{"playqueue_id": 1234}'
CONTENT_ID_BAD_PLAYQUEUE = '{"playqueue_id": 1235}'
requests_mock.get("https://plex.tv/users/account", text=plextv_account) CONTENT_ID_SERVER = '{"plex_server": "Plex Server 1", "library_name": "Music", "artist_name": "Artist"}'
requests_mock.post("/playqueues", text=playqueue_created) CONTENT_ID_SHUFFLE = (
playback_mock = requests_mock.get( '{"library_name": "Music", "artist_name": "Artist", "shuffle": 1}'
"/player/playback/playMedia", status_code=HTTPStatus.OK
) )
# Test with no Plex integration available # Test with no Plex integration available
with pytest.raises(HomeAssistantError) as excinfo: 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) assert "Plex integration not configured" in str(excinfo.value)
with patch( with patch(
@ -147,68 +145,45 @@ async def test_sonos_play_media(
# Test with no Plex servers available # Test with no Plex servers available
with pytest.raises(HomeAssistantError) as excinfo: 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) assert "No Plex servers available" in str(excinfo.value)
# Complete setup of a Plex server # Complete setup of a Plex server
await hass.config_entries.async_unload(entry.entry_id) 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 # Test lookup success
requests_mock.get("https://sonos.plex.tv/resources", status_code=403) result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID)
with pytest.raises(HomeAssistantError) as excinfo: assert isinstance(result, plexapi.audio.Artist)
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 with no speakers available # Test media key payload
requests_mock.get("https://sonos.plex.tv/resources", text=empty_payload) result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_KEY)
with pytest.raises(HomeAssistantError) as excinfo: assert isinstance(result, plexapi.audio.Track)
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 with speakers available # Test with specified server
requests_mock.get("https://sonos.plex.tv/resources", text=sonos_resources) result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_SERVER)
with patch.object(mock_plex_server.account, "_sonos_cache_timestamp", 0): assert isinstance(result, plexapi.audio.Artist)
play_on_sonos(hass, MEDIA_TYPE_MUSIC, media_content_id, sonos_speaker_name)
assert playback_mock.call_count == 1
# Test with speakers available and media key payload # Test with media not found
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"}'
with patch("plexapi.library.LibrarySection.search", return_value=None): with patch("plexapi.library.LibrarySection.search", return_value=None):
with pytest.raises(HomeAssistantError) as excinfo: with pytest.raises(HomeAssistantError) as excinfo:
play_on_sonos( lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_BAD_MEDIA)
hass, MEDIA_TYPE_MUSIC, content_id_bad_media, sonos_speaker_name
)
assert "Plex media not found" in str(excinfo.value) 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) requests_mock.get("https://1.2.3.4:32400/playQueues/1234", text=playqueue_1234)
content_id_with_playqueue = '{"playqueue_id": 1234}' result = lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_PLAYQUEUE)
play_on_sonos(hass, MEDIA_TYPE_MUSIC, content_id_with_playqueue, sonos_speaker_name) assert isinstance(result, plexapi.playqueue.PlayQueue)
assert playback_mock.call_count == 4
# Test with speakers available and invalid playqueue # Test with invalid playqueue
requests_mock.get( requests_mock.get(
"https://1.2.3.4:32400/playQueues/1235", status_code=HTTPStatus.NOT_FOUND "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: with pytest.raises(HomeAssistantError) as excinfo:
play_on_sonos( lookup_plex_media(hass, MEDIA_TYPE_MUSIC, CONTENT_ID_BAD_PLAYQUEUE)
hass, MEDIA_TYPE_MUSIC, content_id_with_playqueue, sonos_speaker_name
)
assert "PlayQueue '1235' could not be found" in str(excinfo.value) 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)

View file

@ -23,8 +23,12 @@ async def test_plex_play_media(hass, async_autosetup_sonos):
) )
with patch( with patch(
"homeassistant.components.sonos.media_player.play_on_sonos" "homeassistant.components.sonos.media_player.lookup_plex_media"
) as mock_play: ) 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 # Test successful Plex service call
assert await hass.services.async_call( assert await hass.services.async_call(
MP_DOMAIN, MP_DOMAIN,
@ -37,14 +41,38 @@ async def test_plex_play_media(hass, async_autosetup_sonos):
blocking=True, blocking=True,
) )
assert len(mock_play.mock_calls) == 1 assert len(mock_lookup.mock_calls) == 1
assert mock_play.mock_calls[0][1][1] == MEDIA_TYPE_MUSIC assert len(mock_play_now.mock_calls) == 1
assert mock_play.mock_calls[0][1][2] == media_content_id assert not mock_shuffle.called
assert mock_play.mock_calls[0][1][3] == "Zone A" 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 # Test failed Plex service call
mock_play.reset_mock() mock_lookup.reset_mock()
mock_play.side_effect = HomeAssistantError mock_lookup.side_effect = HomeAssistantError
mock_play_now.reset_mock()
with pytest.raises(HomeAssistantError): with pytest.raises(HomeAssistantError):
await hass.services.async_call( await hass.services.async_call(
@ -57,4 +85,5 @@ async def test_plex_play_media(hass, async_autosetup_sonos):
}, },
blocking=True, blocking=True,
) )
assert mock_play.called assert mock_lookup.called
assert not mock_play_now.called