PlexKodiConnect/resources/lib/plex_companion/polling.py

165 lines
6.1 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import logging
import requests
from .processing import process_command
from .common import communicate, log_error, create_requests_session, \
b_ok_message
from .. import utils
from .. import backgroundthread
from .. import app
# Timeout (connection timeout, read timeout)
# The later is up to 20 seconds, if the PMS has nothing to tell us
# THIS WILL PREVENT PKC FROM SHUTTING DOWN CORRECTLY
TIMEOUT = (5.0, 4.0)
# Max. timeout for the Polling: 2 ^ MAX_TIMEOUT
# Corresponds to 2 ^ 7 = 128 seconds
MAX_TIMEOUT = 7
log = logging.getLogger('PLEX.companion.polling')
class Polling(backgroundthread.KillableThread):
"""
Opens a GET HTTP connection to the current PMS (that will time-out PMS-wise
after ~20 seconds) and listens for any commands by the PMS. Listening
will cause this PKC client to be registered as a Plex Companien client.
"""
daemon = True
def __init__(self, playstate_mgr):
self.s = None
self.playstate_mgr = playstate_mgr
self._sleep_timer = 0
self.ok_msg = b_ok_message()
super().__init__()
def _get_requests_session(self):
if self.s is None:
log.debug('Creating new requests session')
self.s = create_requests_session()
return self.s
def close_requests_session(self):
try:
self.s.close()
except AttributeError:
# "thread-safety" - Just in case s was set to None in the
# meantime
pass
self.s = None
def _unauthorized(self):
"""Puts this thread to sleep until e.g. a PMS changes wakes it up"""
log.warn('We are not authorized to poll the PMS (http error 401). '
'Plex Companion will not work.')
self.suspend()
def _on_connection_error(self, req=None, error=None):
if req:
log_error(log.error, 'Error while contacting the PMS', req)
if error:
log.error('Error encountered: %s: %s', type(error), error)
self.sleep(2 ** self._sleep_timer)
if self._sleep_timer < MAX_TIMEOUT:
self._sleep_timer += 1
def ok_message(self, command_id):
url = f'{app.CONN.server}/player/proxy/response?commandID={command_id}'
try:
req = communicate(self.s.post, url, data=self.ok_msg)
except (requests.RequestException, SystemExit) as error:
log.debug('Error replying with an OK message: %s', error)
return
if not req.ok:
log_error(log.error, 'Error replying with OK message', req)
def run(self):
"""
Ensure that sockets will be closed no matter what
"""
app.APP.register_thread(self)
log.info("----===## Starting PollCompanion ##===----")
try:
self._run()
finally:
self.close_requests_session()
app.APP.deregister_thread(self)
log.info("----===## PollCompanion stopped ##===----")
def _run(self):
while not self.should_cancel():
if self.should_suspend():
self.close_requests_session()
if self.wait_while_suspended():
break
# See if there's anything we need to process
# timeout=1 will cause the PMS to "hold" the connection for approx
# 20 seconds. This will BLOCK requests - not something we can
# circumvent.
url = app.CONN.server + '/player/proxy/poll?timeout=1'
self._get_requests_session()
try:
req = communicate(self.s.get, url, timeout=TIMEOUT)
except (requests.exceptions.ProxyError,
requests.exceptions.SSLError) as error:
self._on_connection_error(req=None, error=error)
continue
except (requests.ConnectionError,
requests.Timeout,
requests.exceptions.ChunkedEncodingError):
# Expected due to timeout and the PMS not having to reply
# No command received from the PMS - try again immediately
continue
except requests.RequestException as error:
self._on_connection_error(req=None, error=error)
continue
except SystemExit:
# We need to quit PKC entirely
break
# Sanity checks
if req.status_code == 401:
# We can't reach a PMS that is not in the local LAN
# This might even lead to e.g. cloudflare blocking us, thinking
# we're staging a DOS attach
self._unauthorized()
continue
elif not req.ok:
self._on_connection_error(req=req, error=None)
continue
elif not ('content-type' in req.headers
and 'xml' in req.headers['content-type']):
self._on_connection_error(req=req, error=None)
continue
if not req.text:
# Means the connection timed-out (usually after 20 seconds),
# because there was no command from the PMS or a client to
# remote-control anything no the PKC-side
# Received an empty body, but still header Content-Type: xml
continue
# Parsing
try:
xml = utils.etree.fromstring(req.content)
cmd = xml[0]
if len(xml) > 1:
# We should always just get ONE command per message
raise IndexError()
except (utils.ParseError, IndexError):
log.error('Could not parse the PMS xml:')
self._on_connection_error(req=req, error=None)
continue
# Do the work
log.debug('Received a Plex Companion command from the PMS:')
utils.log_xml(xml, log.debug, logging.DEBUG)
self.playstate_mgr.check_subscriber(cmd)
if process_command(cmd):
self.ok_message(cmd.get('commandID'))
self._sleep_timer = 0