mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 20:27:08 +00:00
Allow Cast to play Plex media (#41869)
* Allow Cast to play Plex media * Add Plex to after_dependencies, add missing constant * Extract function from Sonos service to allow media lookups * Move to non-async method * Check if media_id exists * Add test to make codecov happy
This commit is contained in:
parent
c8821d930e
commit
e6e4c9cf59
@ -4,7 +4,7 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/cast",
|
"documentation": "https://www.home-assistant.io/integrations/cast",
|
||||||
"requirements": ["pychromecast==7.5.1"],
|
"requirements": ["pychromecast==7.5.1"],
|
||||||
"after_dependencies": ["cloud", "http", "media_source", "tts", "zeroconf"],
|
"after_dependencies": ["cloud", "http", "media_source", "plex", "tts", "zeroconf"],
|
||||||
"zeroconf": ["_googlecast._tcp.local."],
|
"zeroconf": ["_googlecast._tcp.local."],
|
||||||
"codeowners": ["@emontnemery"]
|
"codeowners": ["@emontnemery"]
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ from typing import Optional
|
|||||||
import pychromecast
|
import pychromecast
|
||||||
from pychromecast.controllers.homeassistant import HomeAssistantController
|
from pychromecast.controllers.homeassistant import HomeAssistantController
|
||||||
from pychromecast.controllers.multizone import MultizoneManager
|
from pychromecast.controllers.multizone import MultizoneManager
|
||||||
|
from pychromecast.controllers.plex import PlexController
|
||||||
from pychromecast.quick_play import quick_play
|
from pychromecast.quick_play import quick_play
|
||||||
from pychromecast.socket_client import (
|
from pychromecast.socket_client import (
|
||||||
CONNECTION_STATUS_CONNECTED,
|
CONNECTION_STATUS_CONNECTED,
|
||||||
@ -38,6 +39,8 @@ from homeassistant.components.media_player.const import (
|
|||||||
SUPPORT_VOLUME_MUTE,
|
SUPPORT_VOLUME_MUTE,
|
||||||
SUPPORT_VOLUME_SET,
|
SUPPORT_VOLUME_SET,
|
||||||
)
|
)
|
||||||
|
from homeassistant.components.plex.const import PLEX_URI_SCHEME
|
||||||
|
from homeassistant.components.plex.services import lookup_plex_media
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
EVENT_HOMEASSISTANT_STOP,
|
||||||
STATE_IDLE,
|
STATE_IDLE,
|
||||||
@ -536,6 +539,15 @@ class CastDevice(MediaPlayerEntity):
|
|||||||
quick_play(self._chromecast, app_name, app_data)
|
quick_play(self._chromecast, app_name, app_data)
|
||||||
except NotImplementedError:
|
except NotImplementedError:
|
||||||
_LOGGER.error("App %s not supported", app_name)
|
_LOGGER.error("App %s not supported", app_name)
|
||||||
|
# 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)
|
||||||
|
if media is None:
|
||||||
|
return
|
||||||
|
controller = PlexController()
|
||||||
|
self._chromecast.register_handler(controller)
|
||||||
|
controller.play_media(media)
|
||||||
else:
|
else:
|
||||||
self._chromecast.media_controller.play_media(
|
self._chromecast.media_controller.play_media(
|
||||||
media_id, media_type, **kwargs.get(ATTR_MEDIA_EXTRA, {})
|
media_id, media_type, **kwargs.get(ATTR_MEDIA_EXTRA, {})
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import functools
|
import functools
|
||||||
from functools import partial
|
from functools import partial
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import plexapi.exceptions
|
import plexapi.exceptions
|
||||||
@ -58,7 +57,7 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .errors import ShouldUpdateConfigEntry
|
from .errors import ShouldUpdateConfigEntry
|
||||||
from .server import PlexServer
|
from .server import PlexServer
|
||||||
from .services import async_setup_services
|
from .services import async_setup_services, lookup_plex_media
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__package__)
|
_LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
@ -286,7 +285,7 @@ def play_on_sonos(hass, service_call):
|
|||||||
"""Play Plex media on a linked Sonos device."""
|
"""Play Plex media on a linked Sonos device."""
|
||||||
entity_id = service_call.data[ATTR_ENTITY_ID]
|
entity_id = service_call.data[ATTR_ENTITY_ID]
|
||||||
content_id = service_call.data[ATTR_MEDIA_CONTENT_ID]
|
content_id = service_call.data[ATTR_MEDIA_CONTENT_ID]
|
||||||
content = json.loads(content_id)
|
content_type = service_call.data.get(ATTR_MEDIA_CONTENT_TYPE)
|
||||||
|
|
||||||
sonos = hass.components.sonos
|
sonos = hass.components.sonos
|
||||||
try:
|
try:
|
||||||
@ -295,27 +294,9 @@ def play_on_sonos(hass, service_call):
|
|||||||
_LOGGER.error("Cannot get Sonos device: %s", err)
|
_LOGGER.error("Cannot get Sonos device: %s", err)
|
||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(content, int):
|
media, plex_server = lookup_plex_media(hass, content_type, content_id)
|
||||||
content = {"plex_key": content}
|
if media is None:
|
||||||
content_type = PLEX_DOMAIN
|
|
||||||
else:
|
|
||||||
content_type = "music"
|
|
||||||
|
|
||||||
plex_server_name = content.get("plex_server")
|
|
||||||
shuffle = content.pop("shuffle", 0)
|
|
||||||
|
|
||||||
plex_servers = hass.data[PLEX_DOMAIN][SERVERS].values()
|
|
||||||
if plex_server_name:
|
|
||||||
plex_server = [x for x in plex_servers if x.friendly_name == plex_server_name]
|
|
||||||
if not plex_server:
|
|
||||||
_LOGGER.error(
|
|
||||||
"Requested Plex server '%s' not found in %s",
|
|
||||||
plex_server_name,
|
|
||||||
[x.friendly_name for x in plex_servers],
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
else:
|
|
||||||
plex_server = next(iter(plex_servers))
|
|
||||||
|
|
||||||
sonos_speaker = plex_server.account.sonos_speaker(sonos_name)
|
sonos_speaker = plex_server.account.sonos_speaker(sonos_name)
|
||||||
if sonos_speaker is None:
|
if sonos_speaker is None:
|
||||||
@ -324,11 +305,4 @@ def play_on_sonos(hass, service_call):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
media = plex_server.lookup_media(content_type, **content)
|
sonos_speaker.playMedia(media)
|
||||||
if media is None:
|
|
||||||
_LOGGER.error("Media could not be found: %s", content)
|
|
||||||
return
|
|
||||||
|
|
||||||
_LOGGER.debug("Attempting to play '%s' on %s", media, sonos_speaker)
|
|
||||||
playqueue = plex_server.create_playqueue(media, shuffle=shuffle)
|
|
||||||
sonos_speaker.playMedia(playqueue)
|
|
||||||
|
@ -49,3 +49,5 @@ MANUAL_SETUP_STRING = "Configure Plex server manually"
|
|||||||
SERVICE_PLAY_ON_SONOS = "play_on_sonos"
|
SERVICE_PLAY_ON_SONOS = "play_on_sonos"
|
||||||
SERVICE_REFRESH_LIBRARY = "refresh_library"
|
SERVICE_REFRESH_LIBRARY = "refresh_library"
|
||||||
SERVICE_SCAN_CLIENTS = "scan_for_clients"
|
SERVICE_SCAN_CLIENTS = "scan_for_clients"
|
||||||
|
|
||||||
|
PLEX_URI_SCHEME = "plex://"
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
"""Services for the Plex integration."""
|
"""Services for the Plex integration."""
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from plexapi.exceptions import NotFound
|
from plexapi.exceptions import NotFound
|
||||||
@ -73,19 +74,47 @@ def get_plex_server(hass, plex_server_name=None):
|
|||||||
plex_servers = hass.data[DOMAIN][SERVERS].values()
|
plex_servers = hass.data[DOMAIN][SERVERS].values()
|
||||||
|
|
||||||
if plex_server_name:
|
if plex_server_name:
|
||||||
plex_server = [x for x in plex_servers if x.friendly_name == plex_server_name]
|
plex_server = next(
|
||||||
if not plex_server:
|
(x for x in plex_servers if x.friendly_name == plex_server_name), None
|
||||||
|
)
|
||||||
|
if plex_server is not None:
|
||||||
|
return plex_server
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Requested Plex server '%s' not found in %s",
|
"Requested Plex server '%s' not found in %s",
|
||||||
plex_server_name,
|
plex_server_name,
|
||||||
[x.friendly_name for x in plex_servers],
|
[x.friendly_name for x in plex_servers],
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
elif len(plex_servers) == 1:
|
|
||||||
|
if len(plex_servers) == 1:
|
||||||
return next(iter(plex_servers))
|
return next(iter(plex_servers))
|
||||||
|
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Multiple Plex servers configured and no selection made: %s",
|
"Multiple Plex servers configured, choose with 'plex_server' key: %s",
|
||||||
[x.friendly_name for x in plex_servers],
|
[x.friendly_name for x in plex_servers],
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_plex_media(hass, content_type, content_id):
|
||||||
|
"""Look up Plex media using media_player.play_media service payloads."""
|
||||||
|
content = json.loads(content_id)
|
||||||
|
|
||||||
|
if isinstance(content, int):
|
||||||
|
content = {"plex_key": content}
|
||||||
|
content_type = DOMAIN
|
||||||
|
|
||||||
|
plex_server_name = content.pop("plex_server", None)
|
||||||
|
shuffle = content.pop("shuffle", 0)
|
||||||
|
|
||||||
|
plex_server = get_plex_server(hass, plex_server_name=plex_server_name)
|
||||||
|
if not plex_server:
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
media = plex_server.lookup_media(content_type, **content)
|
||||||
|
if media is None:
|
||||||
|
_LOGGER.error("Media could not be found: %s", content)
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
playqueue = plex_server.create_playqueue(media, shuffle=shuffle)
|
||||||
|
return (playqueue, plex_server)
|
||||||
|
@ -43,6 +43,16 @@ DEFAULT_DATA = {
|
|||||||
},
|
},
|
||||||
const.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][const.CONF_SERVER_IDENTIFIER],
|
const.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][const.CONF_SERVER_IDENTIFIER],
|
||||||
}
|
}
|
||||||
|
SECONDARY_DATA = {
|
||||||
|
const.CONF_SERVER: MOCK_SERVERS[1][const.CONF_SERVER],
|
||||||
|
const.PLEX_SERVER_CONFIG: {
|
||||||
|
CONF_CLIENT_ID: "00000000-0000-0000-0000-000000000002",
|
||||||
|
CONF_TOKEN: MOCK_TOKEN,
|
||||||
|
CONF_URL: f"https://{MOCK_SERVERS[1][CONF_HOST]}:{MOCK_SERVERS[1][CONF_PORT]}",
|
||||||
|
CONF_VERIFY_SSL: True,
|
||||||
|
},
|
||||||
|
const.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[1][const.CONF_SERVER_IDENTIFIER],
|
||||||
|
}
|
||||||
|
|
||||||
DEFAULT_OPTIONS = {
|
DEFAULT_OPTIONS = {
|
||||||
MP_DOMAIN: {
|
MP_DOMAIN: {
|
||||||
|
@ -6,11 +6,19 @@ from homeassistant.components.media_player.const import (
|
|||||||
ATTR_MEDIA_CONTENT_TYPE,
|
ATTR_MEDIA_CONTENT_TYPE,
|
||||||
MEDIA_TYPE_MUSIC,
|
MEDIA_TYPE_MUSIC,
|
||||||
)
|
)
|
||||||
from homeassistant.components.plex.const import DOMAIN, SERVERS, SERVICE_PLAY_ON_SONOS
|
from homeassistant.components.plex.const import (
|
||||||
|
CONF_SERVER,
|
||||||
|
DOMAIN,
|
||||||
|
SERVERS,
|
||||||
|
SERVICE_PLAY_ON_SONOS,
|
||||||
|
)
|
||||||
from homeassistant.const import ATTR_ENTITY_ID
|
from homeassistant.const import ATTR_ENTITY_ID
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
|
||||||
|
from .const import DEFAULT_OPTIONS, SECONDARY_DATA
|
||||||
|
|
||||||
from tests.async_mock import patch
|
from tests.async_mock import patch
|
||||||
|
from tests.common import MockConfigEntry
|
||||||
|
|
||||||
|
|
||||||
async def test_sonos_playback(hass, mock_plex_server):
|
async def test_sonos_playback(hass, mock_plex_server):
|
||||||
@ -108,6 +116,8 @@ async def test_sonos_playback(hass, mock_plex_server):
|
|||||||
hass.components.sonos,
|
hass.components.sonos,
|
||||||
"get_coordinator_name",
|
"get_coordinator_name",
|
||||||
return_value="media_player.sonos_kitchen",
|
return_value="media_player.sonos_kitchen",
|
||||||
|
), patch(
|
||||||
|
"plexapi.playqueue.PlayQueue.create"
|
||||||
):
|
):
|
||||||
assert await hass.services.async_call(
|
assert await hass.services.async_call(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -119,3 +129,32 @@ async def test_sonos_playback(hass, mock_plex_server):
|
|||||||
},
|
},
|
||||||
True,
|
True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_playback_multiple_servers(hass, mock_websocket, setup_plex_server):
|
||||||
|
"""Test playing media when multiple servers available."""
|
||||||
|
secondary_entry = MockConfigEntry(
|
||||||
|
domain=DOMAIN,
|
||||||
|
data=SECONDARY_DATA,
|
||||||
|
options=DEFAULT_OPTIONS,
|
||||||
|
unique_id=SECONDARY_DATA["server_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
await setup_plex_server()
|
||||||
|
await setup_plex_server(config_entry=secondary_entry)
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
hass.components.sonos,
|
||||||
|
"get_coordinator_name",
|
||||||
|
return_value="media_player.sonos_kitchen",
|
||||||
|
), patch("plexapi.playqueue.PlayQueue.create"):
|
||||||
|
assert await hass.services.async_call(
|
||||||
|
DOMAIN,
|
||||||
|
SERVICE_PLAY_ON_SONOS,
|
||||||
|
{
|
||||||
|
ATTR_ENTITY_ID: "media_player.sonos_kitchen",
|
||||||
|
ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC,
|
||||||
|
ATTR_MEDIA_CONTENT_ID: f'{{"plex_server": "{SECONDARY_DATA[CONF_SERVER]}", "library_name": "Music", "artist_name": "Artist", "album_name": "Album"}}',
|
||||||
|
},
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user