bazarr/bazarr/app/signalr_client.py

362 lines
14 KiB
Python

# coding=utf-8
import logging
import json
import time
import threading
from requests import Session
from signalr import Connection
from requests.exceptions import ConnectionError
from signalrcore.hub_connection_builder import HubConnectionBuilder
from collections import deque
from time import sleep
from constants import headers
from app.event_handler import event_stream
from sonarr.sync.episodes import sync_episodes, sync_one_episode
from sonarr.sync.series import update_series, update_one_series
from radarr.sync.movies import update_movies, update_one_movie
from sonarr.info import get_sonarr_info, url_sonarr
from radarr.info import url_radarr
from .database import TableShows, TableMovies, database, select
from .config import settings
from .scheduler import scheduler
from .get_args import args
sonarr_queue = deque()
radarr_queue = deque()
last_event_data = None
class SonarrSignalrClientLegacy:
def __init__(self):
super(SonarrSignalrClientLegacy, self).__init__()
self.apikey_sonarr = None
self.session = Session()
self.session.timeout = 60
self.session.verify = False
self.session.headers = headers
self.connection = None
self.connected = False
def start(self):
if get_sonarr_info.is_legacy():
logging.warning(
f'BAZARR can only sync from Sonarr v3 SignalR feed to get real-time update. You should consider '
f'upgrading your version({get_sonarr_info.version()}).')
else:
self.connected = False
event_stream(type='badges')
logging.info('BAZARR trying to connect to Sonarr SignalR feed...')
self.configure()
while not self.connection.started:
try:
self.connection.start()
except ConnectionError:
time.sleep(5)
except json.decoder.JSONDecodeError:
logging.error("BAZARR cannot parse JSON returned by SignalR feed. This is caused by a permissions "
"issue when Sonarr try to access its /config/.config directory."
"Typically permissions are too permissive - only the user and group Sonarr runs as "
"should have Read/Write permissions (e.g. files 664 / folders 775). You should fix "
"permissions on that directory and restart Sonarr. Also, if you're a Docker image "
"user, you should make sure you properly defined PUID/PGID environment variables. "
"Otherwise, please contact Sonarr support.")
self.stop()
break
else:
self.connected = True
event_stream(type='badges')
logging.info('BAZARR SignalR client for Sonarr is connected and waiting for events.')
if not args.dev:
scheduler.add_job(update_series, kwargs={'send_event': True}, max_instances=1)
def stop(self, log=True):
try:
self.connection.close()
except Exception:
self.connection.started = False
if log:
logging.info('BAZARR SignalR client for Sonarr is now disconnected.')
def restart(self):
if self.connection:
if self.connection.started:
self.stop(log=False)
if settings.general.use_sonarr:
self.start()
def exception_handler(self):
sonarr_queue.clear()
self.connected = False
event_stream(type='badges')
logging.error('BAZARR connection to Sonarr SignalR feed has been lost.')
self.restart()
def configure(self):
self.apikey_sonarr = settings.sonarr.apikey
self.connection = Connection(f"{url_sonarr()}/signalr", self.session)
self.connection.qs = {'apikey': self.apikey_sonarr}
sonarr_hub = self.connection.register_hub('') # Sonarr doesn't use named hub
sonarr_method = ['series', 'episode']
for item in sonarr_method:
sonarr_hub.client.on(item, feed_queue)
self.connection.exception += self.exception_handler
class SonarrSignalrClient:
def __init__(self):
super(SonarrSignalrClient, self).__init__()
self.apikey_sonarr = None
self.connection = None
self.connected = False
def start(self):
self.configure()
logging.info('BAZARR trying to connect to Sonarr SignalR feed...')
while self.connection.transport.state.value not in [0, 1, 2]:
try:
self.connection.start()
except ConnectionError:
time.sleep(5)
def stop(self):
logging.info('BAZARR SignalR client for Sonarr is now disconnected.')
self.connection.stop()
def restart(self):
if self.connection:
if self.connection.transport.state.value in [0, 1, 2]:
self.stop()
if settings.general.use_sonarr:
self.start()
def exception_handler(self):
sonarr_queue.clear()
self.connected = False
event_stream(type='badges')
logging.error("BAZARR connection to Sonarr SignalR feed has failed. We'll try to reconnect.")
self.restart()
def on_connect_handler(self):
self.connected = True
event_stream(type='badges')
logging.info('BAZARR SignalR client for Sonarr is connected and waiting for events.')
if not args.dev:
scheduler.add_job(update_series, kwargs={'send_event': True}, max_instances=1)
def on_reconnect_handler(self):
self.connected = False
event_stream(type='badges')
logging.error('BAZARR SignalR client for Sonarr connection as been lost. Trying to reconnect...')
def configure(self):
self.apikey_sonarr = settings.sonarr.apikey
self.connection = HubConnectionBuilder() \
.with_url(f"{url_sonarr()}/signalr/messages?access_token={self.apikey_sonarr}",
options={
"verify_ssl": False,
"headers": headers
}) \
.with_automatic_reconnect({
"type": "raw",
"keep_alive_interval": 5,
"reconnect_interval": 180,
"max_attempts": None
}).build()
self.connection.on_open(self.on_connect_handler)
self.connection.on_reconnect(self.on_reconnect_handler)
self.connection.on_close(lambda: logging.debug('BAZARR SignalR client for Sonarr is disconnected.'))
self.connection.on_error(self.exception_handler)
self.connection.on("receiveMessage", feed_queue)
class RadarrSignalrClient:
def __init__(self):
super(RadarrSignalrClient, self).__init__()
self.apikey_radarr = None
self.connection = None
self.connected = False
def start(self):
self.configure()
logging.info('BAZARR trying to connect to Radarr SignalR feed...')
while self.connection.transport.state.value not in [0, 1, 2]:
try:
self.connection.start()
except ConnectionError:
time.sleep(5)
def stop(self):
logging.info('BAZARR SignalR client for Radarr is now disconnected.')
self.connection.stop()
def restart(self):
if self.connection:
if self.connection.transport.state.value in [0, 1, 2]:
self.stop()
if settings.general.use_radarr:
self.start()
def exception_handler(self):
radarr_queue.clear()
self.connected = False
event_stream(type='badges')
logging.error("BAZARR connection to Radarr SignalR feed has failed. We'll try to reconnect.")
self.restart()
def on_connect_handler(self):
self.connected = True
event_stream(type='badges')
logging.info('BAZARR SignalR client for Radarr is connected and waiting for events.')
if not args.dev:
scheduler.add_job(update_movies, kwargs={'send_event': True}, max_instances=1)
def on_reconnect_handler(self):
self.connected = False
event_stream(type='badges')
logging.error('BAZARR SignalR client for Radarr connection as been lost. Trying to reconnect...')
def configure(self):
self.apikey_radarr = settings.radarr.apikey
self.connection = HubConnectionBuilder() \
.with_url(f"{url_radarr()}/signalr/messages?access_token={self.apikey_radarr}",
options={
"verify_ssl": False,
"headers": headers
}) \
.with_automatic_reconnect({
"type": "raw",
"keep_alive_interval": 5,
"reconnect_interval": 180,
"max_attempts": None
}).build()
self.connection.on_open(self.on_connect_handler)
self.connection.on_reconnect(self.on_reconnect_handler)
self.connection.on_close(lambda: logging.debug('BAZARR SignalR client for Radarr is disconnected.'))
self.connection.on_error(self.exception_handler)
self.connection.on("receiveMessage", feed_queue)
def dispatcher(data):
try:
series_title = series_year = episode_title = season_number = episode_number = movie_title = movie_year = None
#
try:
episodesChanged = False
topic = data['name']
media_id = data['body']['resource']['id']
action = data['body']['action']
if topic == 'series':
if 'episodesChanged' in data['body']['resource']:
episodesChanged = data['body']['resource']['episodesChanged']
series_title = data['body']['resource']['title']
series_year = data['body']['resource']['year']
elif topic == 'episode':
if 'series' in data['body']['resource']:
series_title = data['body']['resource']['series']['title']
series_year = data['body']['resource']['series']['year']
else:
series_metadata = database.execute(
select(TableShows.title, TableShows.year)
.where(TableShows.sonarrSeriesId == data['body']['resource']['seriesId']))\
.first()
if series_metadata:
series_title = series_metadata.title
series_year = series_metadata.year
episode_title = data['body']['resource']['title']
season_number = data['body']['resource']['seasonNumber']
episode_number = data['body']['resource']['episodeNumber']
elif topic == 'movie':
if action == 'deleted':
existing_movie_details = database.execute(
select(TableMovies.title, TableMovies.year)
.where(TableMovies.radarrId == media_id)) \
.first()
if existing_movie_details:
movie_title = existing_movie_details.title
movie_year = existing_movie_details.year
else:
return
else:
movie_title = data['body']['resource']['title']
movie_year = data['body']['resource']['year']
except KeyError:
return
if topic == 'series':
logging.debug(f'Event received from Sonarr for series: {series_title} ({series_year})')
update_one_series(series_id=media_id, action=action)
if episodesChanged:
# this will happen if a season monitored status is changed.
sync_episodes(series_id=media_id, send_event=True)
elif topic == 'episode':
logging.debug(f'Event received from Sonarr for episode: {series_title} ({series_year}) - '
f'S{season_number:0>2}E{episode_number:0>2} - {episode_title}')
sync_one_episode(episode_id=media_id, defer_search=settings.sonarr.defer_search_signalr)
elif topic == 'movie':
logging.debug(f'Event received from Radarr for movie: {movie_title} ({movie_year})')
update_one_movie(movie_id=media_id, action=action,
defer_search=settings.radarr.defer_search_signalr)
except Exception as e:
logging.debug(f'BAZARR an exception occurred while parsing SignalR feed: {repr(e)}')
finally:
event_stream(type='badges')
return
def feed_queue(data):
# check if event is duplicate from the previous one
global last_event_data
if data == last_event_data:
return
else:
last_event_data = data
# some sonarr version send event as a list of a single dict, we make it a dict
if isinstance(data, list) and len(data):
data = data[0]
# if data is a dict and contain an event for series, episode or movie, we add it to the event queue
if isinstance(data, dict) and 'name' in data:
if data['name'] in ['series', 'episode']:
sonarr_queue.append(data)
elif data['name'] == 'movie':
radarr_queue.append(data)
def consume_queue(queue):
# get events data from queue one at a time and dispatch it
while True:
try:
data = queue.popleft()
except IndexError:
pass
except (KeyboardInterrupt, SystemExit):
break
else:
dispatcher(data)
sleep(0.1)
# start both queue consuming threads
sonarr_queue_thread = threading.Thread(target=consume_queue, args=(sonarr_queue,))
sonarr_queue_thread.daemon = True
sonarr_queue_thread.start()
radarr_queue_thread = threading.Thread(target=consume_queue, args=(radarr_queue,))
radarr_queue_thread.daemon = True
radarr_queue_thread.start()
# instantiate proper SignalR client
sonarr_signalr_client = SonarrSignalrClientLegacy() if get_sonarr_info.version().startswith(('0.', '2.', '3.')) else \
SonarrSignalrClient()
radarr_signalr_client = RadarrSignalrClient()