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
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()

View File

@ -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

View File

@ -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

View File

@ -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."""

View File

@ -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(),

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):
"""Test playing media."""
entity_id = "media_player.speaker"

View File

@ -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)

View File

@ -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