Home Assistant Unofficial Reference 2024.12.1
__init__.py
Go to the documentation of this file.
1 """Support to embed Plex."""
2 
3 from functools import partial
4 import logging
5 
6 import plexapi.exceptions
7 from plexapi.gdm import GDM
8 from plexwebsocket import (
9  SIGNAL_CONNECTION_STATE,
10  STATE_CONNECTED,
11  STATE_DISCONNECTED,
12  STATE_STOPPED,
13  PlexWebsocket,
14 )
15 import requests.exceptions
16 
17 from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, BrowseError
18 from homeassistant.config_entries import ConfigEntry
19 from homeassistant.const import CONF_URL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP
20 from homeassistant.core import HomeAssistant, callback
21 from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
22 from homeassistant.helpers import (
23  config_validation as cv,
24  device_registry as dr,
25  entity_registry as er,
26 )
27 from homeassistant.helpers.aiohttp_client import async_get_clientsession
28 from homeassistant.helpers.debounce import Debouncer
30  async_dispatcher_connect,
31  async_dispatcher_send,
32 )
33 from homeassistant.helpers.event import async_track_time_interval
34 from homeassistant.helpers.network import is_internal_request
35 from homeassistant.helpers.typing import ConfigType
36 
37 from .const import (
38  CLIENT_SCAN_INTERVAL,
39  CONF_SERVER,
40  CONF_SERVER_IDENTIFIER,
41  DISPATCHERS,
42  DOMAIN,
43  INVALID_TOKEN_MESSAGE,
44  PLATFORMS,
45  PLEX_SERVER_CONFIG,
46  PLEX_UPDATE_LIBRARY_SIGNAL,
47  PLEX_UPDATE_PLATFORMS_SIGNAL,
48  PLEX_URI_SCHEME,
49  SERVERS,
50  WEBSOCKETS,
51 )
52 from .errors import ShouldUpdateConfigEntry
53 from .helpers import PlexData, get_plex_data
54 from .media_browser import browse_media
55 from .server import PlexServer
56 from .services import async_setup_services
57 from .view import PlexImageView
58 
59 _LOGGER = logging.getLogger(__package__)
60 
61 CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
62 
63 
64 def is_plex_media_id(media_content_id):
65  """Return whether the media_content_id is a valid Plex media_id."""
66  return media_content_id and media_content_id.startswith(PLEX_URI_SCHEME)
67 
68 
69 async def async_browse_media(hass, media_content_type, media_content_id, platform=None):
70  """Browse Plex media."""
71  plex_server = next(iter(get_plex_data(hass)[SERVERS].values()), None)
72  if not plex_server:
73  raise BrowseError("No Plex servers available")
74  is_internal = is_internal_request(hass)
75  return await hass.async_add_executor_job(
76  partial(
77  browse_media,
78  hass,
79  is_internal,
80  media_content_type,
81  media_content_id,
82  platform=platform,
83  )
84  )
85 
86 
87 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
88  """Set up the Plex component."""
89  gdm = GDM()
90 
91  def gdm_scan():
92  _LOGGER.debug("Scanning for GDM clients")
93  gdm.scan(scan_for_clients=True)
94 
95  debouncer = Debouncer[None](
96  hass, _LOGGER, cooldown=10, immediate=True, function=gdm_scan, background=True
97  ).async_call
98 
99  hass_data = PlexData(
100  servers={},
101  dispatchers={},
102  websockets={},
103  gdm_scanner=gdm,
104  gdm_debouncer=debouncer,
105  )
106  hass.data.setdefault(DOMAIN, hass_data)
107 
108  await async_setup_services(hass)
109 
110  hass.http.register_view(PlexImageView())
111 
112  return True
113 
114 
115 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
116  """Set up Plex from a config entry."""
117  server_config = entry.data[PLEX_SERVER_CONFIG]
118 
119  if entry.unique_id is None:
120  hass.config_entries.async_update_entry(
121  entry, unique_id=entry.data[CONF_SERVER_IDENTIFIER]
122  )
123 
124  if MP_DOMAIN not in entry.options:
125  options = dict(entry.options)
126  options.setdefault(MP_DOMAIN, {})
127  hass.config_entries.async_update_entry(entry, options=options)
128 
129  plex_server = PlexServer(
130  hass,
131  server_config,
132  entry.data[CONF_SERVER_IDENTIFIER],
133  entry.options,
134  entry.entry_id,
135  )
136  try:
137  await hass.async_add_executor_job(plex_server.connect)
138  except ShouldUpdateConfigEntry:
139  new_server_data = {
140  **entry.data[PLEX_SERVER_CONFIG],
141  CONF_URL: plex_server.url_in_use,
142  CONF_SERVER: plex_server.friendly_name,
143  }
144  hass.config_entries.async_update_entry(
145  entry, data={**entry.data, PLEX_SERVER_CONFIG: new_server_data}
146  )
147  except requests.exceptions.ConnectionError as error:
148  raise ConfigEntryNotReady from error
149  except plexapi.exceptions.Unauthorized as ex:
150  raise ConfigEntryAuthFailed(
151  "Token not accepted, please reauthenticate Plex server"
152  f" '{entry.data[CONF_SERVER]}'"
153  ) from ex
154  except (
155  plexapi.exceptions.BadRequest,
156  plexapi.exceptions.NotFound,
157  ) as error:
158  if INVALID_TOKEN_MESSAGE in str(error):
159  raise ConfigEntryAuthFailed(
160  "Token not accepted, please reauthenticate Plex server"
161  f" '{entry.data[CONF_SERVER]}'"
162  ) from error
163  _LOGGER.error(
164  "Login to %s failed, verify token and SSL settings: [%s]",
165  entry.data[CONF_SERVER],
166  error,
167  )
168  # Retry as setups behind a proxy can return transient 404 or 502 errors
169  raise ConfigEntryNotReady from error
170 
171  _LOGGER.debug(
172  "Connected to: %s (%s)", plex_server.friendly_name, plex_server.url_in_use
173  )
174  server_id = plex_server.machine_identifier
175  hass_data = get_plex_data(hass)
176  hass_data[SERVERS][server_id] = plex_server
177 
178  entry.add_update_listener(async_options_updated)
179 
180  unsub = async_dispatcher_connect(
181  hass,
182  PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id),
183  plex_server.async_update_platforms,
184  )
185  hass_data[DISPATCHERS].setdefault(server_id, [])
186  hass_data[DISPATCHERS][server_id].append(unsub)
187 
188  @callback
189  def plex_websocket_callback(msgtype, data, error):
190  """Handle callbacks from plexwebsocket library."""
191  if msgtype == SIGNAL_CONNECTION_STATE:
192  if data == STATE_CONNECTED:
193  _LOGGER.debug("Websocket to %s successful", entry.data[CONF_SERVER])
194  hass.async_create_task(plex_server.async_update_platforms())
195  elif data == STATE_DISCONNECTED:
196  _LOGGER.debug(
197  "Websocket to %s disconnected, retrying", entry.data[CONF_SERVER]
198  )
199  # Stopped websockets without errors are expected during shutdown and ignored
200  elif data == STATE_STOPPED and error:
201  _LOGGER.error(
202  "Websocket to %s failed, aborting [Error: %s]",
203  entry.data[CONF_SERVER],
204  error,
205  )
206  hass.async_create_task(hass.config_entries.async_reload(entry.entry_id))
207 
208  elif msgtype == "playing":
209  hass.async_create_task(plex_server.async_update_session(data))
210  elif msgtype == "status":
211  if data["StatusNotification"][0]["title"] == "Library scan complete":
213  hass,
214  PLEX_UPDATE_LIBRARY_SIGNAL.format(server_id),
215  )
216 
217  session = async_get_clientsession(hass)
218  subscriptions = ["playing", "status"]
219  verify_ssl = server_config.get(CONF_VERIFY_SSL)
220  websocket = PlexWebsocket(
221  plex_server.plex_server,
222  plex_websocket_callback,
223  subscriptions=subscriptions,
224  session=session,
225  verify_ssl=verify_ssl,
226  )
227  hass_data[WEBSOCKETS][server_id] = websocket
228 
229  def close_websocket_session(_):
230  websocket.close()
231 
232  unsub = hass.bus.async_listen_once(
233  EVENT_HOMEASSISTANT_STOP, close_websocket_session
234  )
235  hass_data[DISPATCHERS][server_id].append(unsub)
236 
237  await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
238 
239  entry.async_create_background_task(
240  hass, websocket.listen(), f"plex websocket listener {entry.entry_id}"
241  )
242 
243  async_cleanup_plex_devices(hass, entry)
244 
245  def get_plex_account(plex_server):
246  try:
247  return plex_server.account
248  except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized):
249  return None
250 
251  await hass.async_add_executor_job(get_plex_account, plex_server)
252 
253  @callback
254  def scheduled_client_scan(_):
255  _LOGGER.debug("Scheduled scan for new clients on %s", plex_server.friendly_name)
256  async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id))
257 
258  entry.async_on_unload(
260  hass,
261  scheduled_client_scan,
262  CLIENT_SCAN_INTERVAL,
263  )
264  )
265 
266  return True
267 
268 
269 async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
270  """Unload a config entry."""
271  server_id = entry.data[CONF_SERVER_IDENTIFIER]
272 
273  hass_data = get_plex_data(hass)
274  websocket = hass_data[WEBSOCKETS].pop(server_id)
275  websocket.close()
276 
277  dispatchers = hass_data[DISPATCHERS].pop(server_id)
278  for unsub in dispatchers:
279  unsub()
280 
281  unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
282 
283  hass_data[SERVERS].pop(server_id)
284 
285  return unload_ok
286 
287 
288 async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None:
289  """Triggered by config entry options updates."""
290  server_id = entry.data[CONF_SERVER_IDENTIFIER]
291 
292  hass_data = get_plex_data(hass)
293  # Guard incomplete setup during reauth flows
294  if server_id in hass_data[SERVERS]:
295  hass_data[SERVERS][server_id].options = entry.options
296 
297 
298 @callback
299 def async_cleanup_plex_devices(hass, entry):
300  """Clean up old and invalid devices from the registry."""
301  device_registry = dr.async_get(hass)
302  entity_registry = er.async_get(hass)
303 
304  device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
305 
306  for device_entry in device_entries:
307  if (
308  len(
309  er.async_entries_for_device(
310  entity_registry, device_entry.id, include_disabled_entities=True
311  )
312  )
313  == 0
314  ):
315  _LOGGER.debug(
316  "Removing orphaned device: %s / %s",
317  device_entry.name,
318  device_entry.identifiers,
319  )
320  device_registry.async_remove_device(device_entry.id)
None async_setup_services(HomeAssistant hass)
Definition: __init__.py:72
PlexData get_plex_data(HomeAssistant hass)
Definition: helpers.py:29
def is_plex_media_id(media_content_id)
Definition: __init__.py:64
def async_browse_media(hass, media_content_type, media_content_id, platform=None)
Definition: __init__.py:69
def async_cleanup_plex_devices(hass, entry)
Definition: __init__.py:299
None async_options_updated(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:288
bool async_setup_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:115
bool async_setup(HomeAssistant hass, ConfigType config)
Definition: __init__.py:87
bool async_unload_entry(HomeAssistant hass, ConfigEntry entry)
Definition: __init__.py:269
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193
CALLBACK_TYPE async_track_time_interval(HomeAssistant hass, Callable[[datetime], Coroutine[Any, Any, None]|None] action, timedelta interval, *str|None name=None, bool|None cancel_on_shutdown=None)
Definition: event.py:1679
bool is_internal_request(HomeAssistant hass)
Definition: network.py:31