mirror of
https://github.com/home-assistant/core.git
synced 2025-04-23 16:57:53 +00:00
Use local Sonos API for Plex music playback (#63357)
This commit is contained in:
parent
cf7148c3f8
commit
cdad1a9f27
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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(),
|
||||
|
@ -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"
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user