bazarr/libs/apprise/plugins/NotifyEmby.py

586 lines
18 KiB
Python

# -*- coding: utf-8 -*-
#
# Emby Notify Wrapper
#
# Copyright (C) 2017-2018 Chris Caron <lead2gold@gmail.com>
#
# This file is part of apprise.
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
# For this plugin to work correct, the Emby server must be set up to allow
# for remote connections.
# Emby Docker configuration: https://hub.docker.com/r/emby/embyserver/
# Authentication: https://github.com/MediaBrowser/Emby/wiki/Authentication
# Notifications: https://github.com/MediaBrowser/Emby/wiki/Remote-control
import requests
import hashlib
from json import dumps
from json import loads
from .NotifyBase import NotifyBase
from .NotifyBase import HTTP_ERROR_MAP
from ..utils import parse_bool
from .. import __version__ as VERSION
class NotifyEmby(NotifyBase):
"""
A wrapper for Emby Notifications
"""
# The default descriptive name associated with the Notification
service_name = 'Emby'
# The services URL
service_url = 'https://emby.media/'
# The default protocol
protocol = 'emby'
# The default secure protocol
secure_protocol = 'embys'
# A URL that takes you to the setup/help of the specific protocol
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_emby'
# Emby uses the http protocol with JSON requests
emby_default_port = 8096
# By default Emby requires you to provide it a device id
# The following was just a random uuid4 generated one. There
# is no real reason to change this, but hey; that's what open
# source is for right?
emby_device_id = '48df9504-6843-49be-9f2d-a685e25a0bc8'
# The Emby message timeout; basically it is how long should our message be
# displayed for. The value is in milli-seconds
emby_message_timeout_ms = 60000
def __init__(self, modal=False, **kwargs):
"""
Initialize Emby Object
"""
super(NotifyEmby, self).__init__(**kwargs)
if self.secure:
self.schema = 'https'
else:
self.schema = 'http'
# Our access token does not get created until we first
# authenticate with our Emby server. The same goes for the
# user id below.
self.access_token = None
self.user_id = None
# Whether or not our popup dialog is a timed notification
# or a modal type box (requires an Okay acknowledgement)
self.modal = modal
if not self.user:
# Token was None
self.logger.warning('No Username was specified.')
raise TypeError('No Username was specified.')
return
def login(self, **kwargs):
"""
Creates our authentication token and prepares our header
"""
if self.is_authenticated:
# Log out first before we log back in
self.logout()
# Prepare our login url
url = '%s://%s' % (self.schema, self.host)
if self.port:
url += ':%d' % self.port
url += '/Users/AuthenticateByName'
# Initialize our payload
payload = {
'Username': self.user
}
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json',
'X-Emby-Authorization': self.emby_auth_header,
}
if self.password:
# Source: https://github.com/MediaBrowser/Emby/wiki/Authentication
# We require the following during our authentication
# pw - password in plain text
# password - password in Sha1
# passwordMd5 - password in MD5
payload['pw'] = self.password
password_md5 = hashlib.md5()
password_md5.update(self.password.encode('utf-8'))
payload['passwordMd5'] = password_md5.hexdigest()
password_sha1 = hashlib.sha1()
password_sha1.update(self.password.encode('utf-8'))
payload['password'] = password_sha1.hexdigest()
else:
# Backwards compatibility
payload['password'] = ''
payload['passwordMd5'] = ''
# April 1st, 2018 and newer requirement:
payload['pw'] = ''
self.logger.debug(
'Emby login() POST URL: %s (cert_verify=%r)' % (
url, self.verify_certificate))
try:
r = requests.post(
url,
headers=headers,
data=dumps(payload),
verify=self.verify_certificate,
)
if r.status_code != requests.codes.ok:
try:
self.logger.warning(
'Failed to authenticate user %s details: '
'%s (error=%s).' % (
self.user,
HTTP_ERROR_MAP[r.status_code],
r.status_code))
except KeyError:
self.logger.warning(
'Failed to authenticate user %s details: '
'(error=%s).' % (self.user, r.status_code))
self.logger.debug('Emby Response:\r\n%s' % r.text)
# Return; we're done
return False
except requests.RequestException as e:
self.logger.warning(
'A Connection error occured authenticating a user with Emby '
'at %s.' % self.host)
self.logger.debug('Socket Exception: %s' % str(e))
# Return; we're done
return False
# Load our results
try:
results = loads(r.content)
except ValueError:
# A string like '' would cause this; basicallly the content
# that was provided was not a JSON string. We can stop here
return False
# Acquire our Access Token
self.access_token = results.get('AccessToken')
# Acquire our UserId. It can be in one (or both) of the
# following locations in the response:
# {
# 'User': {
# ...
# 'Id': 'the_user_id_can_be_here',
# ...
# },
# 'Id': 'the_user_id_can_be_found_here_too',
# }
#
# The below just safely covers both grounds.
self.user_id = results.get('Id')
if not self.user_id:
if 'User' in results:
self.user_id = results['User'].get('Id')
# No user was found matching the specified
return self.is_authenticated
def sessions(self, user_controlled=True):
"""
Acquire our Session Identifiers and store them in a dictionary
indexed by the session id itself.
"""
# A single session might look like this:
# {
# u'AdditionalUsers': [],
# u'ApplicationVersion': u'3.3.1.0',
# u'Client': u'Emby Mobile',
# u'DeviceId': u'00c901e90ae814c00f81c75ae06a1c8a4381f45b',
# u'DeviceName': u'Firefox',
# u'Id': u'e37151ea06d7eb636639fded5a80f223',
# u'LastActivityDate': u'2018-03-04T21:29:02.5590200Z',
# u'PlayState': {
# u'CanSeek': False,
# u'IsMuted': False,
# u'IsPaused': False,
# u'RepeatMode': u'RepeatNone',
# },
# u'PlayableMediaTypes': [u'Audio', u'Video'],
# u'RemoteEndPoint': u'172.17.0.1',
# u'ServerId': u'4470e977ea704a08b264628c24127d43',
# u'SupportedCommands': [
# u'MoveUp',
# u'MoveDown',
# u'MoveLeft',
# u'MoveRight',
# u'PageUp',
# u'PageDown',
# u'PreviousLetter',
# u'NextLetter',
# u'ToggleOsd',
# u'ToggleContextMenu',
# u'Select',
# u'Back',
# u'SendKey',
# u'SendString',
# u'GoHome',
# u'GoToSettings',
# u'VolumeUp',
# u'VolumeDown',
# u'Mute',
# u'Unmute',
# u'ToggleMute',
# u'SetVolume',
# u'SetAudioStreamIndex',
# u'SetSubtitleStreamIndex',
# u'DisplayContent',
# u'GoToSearch',
# u'DisplayMessage',
# u'SetRepeatMode',
# u'ChannelUp',
# u'ChannelDown',
# u'PlayMediaSource',
# ],
# u'SupportsRemoteControl': True,
# u'UserId': u'6f98d12cb10f48209ee282787daf7af6',
# u'UserName': u'l2g'
# }
# Prepare a dict() object to control our sessions; the keys are
# the sessions while the details associated with the session
# are stored inside.
sessions = dict()
if not self.is_authenticated and not self.login():
# Authenticate if we aren't already
return sessions
# Prepare our login url
url = '%s://%s' % (self.schema, self.host)
if self.port:
url += ':%d' % self.port
url += '/Sessions'
if user_controlled is True:
# Only return sessions that can be managed by the current Emby
# user.
url += '?ControllableByUserId=%s' % self.user_id
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json',
'X-Emby-Authorization': self.emby_auth_header,
'X-MediaBrowser-Token': self.access_token,
}
self.logger.debug(
'Emby session() GET URL: %s (cert_verify=%r)' % (
url, self.verify_certificate))
try:
r = requests.get(
url,
headers=headers,
verify=self.verify_certificate,
)
if r.status_code != requests.codes.ok:
try:
self.logger.warning(
'Failed to acquire session for user %s details: '
'%s (error=%s).' % (
self.user,
HTTP_ERROR_MAP[r.status_code],
r.status_code))
except KeyError:
self.logger.warning(
'Failed to acquire session for user %s details: '
'(error=%s).' % (self.user, r.status_code))
self.logger.debug('Emby Response:\r\n%s' % r.text)
# Return; we're done
return sessions
except requests.RequestException as e:
self.logger.warning(
'A Connection error occured querying Emby '
'for session information at %s.' % self.host)
self.logger.debug('Socket Exception: %s' % str(e))
# Return; we're done
return sessions
# Load our results
try:
results = loads(r.content)
except ValueError:
# A string like '' would cause this; basicallly the content
# that was provided was not a JSON string. There is nothing
# more we can do at this point
return sessions
for entry in results:
session = entry.get('Id')
if session:
sessions[session] = entry
return sessions
def logout(self, **kwargs):
"""
Logs out of an already-authenticated session
"""
if not self.is_authenticated:
# We're not authenticated; there is nothing to do
return True
# Prepare our login url
url = '%s://%s' % (self.schema, self.host)
if self.port:
url += ':%d' % self.port
url += '/Sessions/Logout'
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json',
'X-Emby-Authorization': self.emby_auth_header,
'X-MediaBrowser-Token': self.access_token,
}
self.logger.debug(
'Emby logout() POST URL: %s (cert_verify=%r)' % (
url, self.verify_certificate))
try:
r = requests.post(
url,
headers=headers,
verify=self.verify_certificate,
)
if r.status_code not in (
# We're already logged out
requests.codes.unauthorized,
# The below show up if we were 'just' logged out
requests.codes.ok,
requests.codes.no_content):
try:
self.logger.warning(
'Failed to logoff user %s details: '
'%s (error=%s).' % (
self.user,
HTTP_ERROR_MAP[r.status_code],
r.status_code))
except KeyError:
self.logger.warning(
'Failed to logoff user %s details: '
'(error=%s).' % (self.user, r.status_code))
self.logger.debug('Emby Response:\r\n%s' % r.text)
# Return; we're done
return False
except requests.RequestException as e:
self.logger.warning(
'A Connection error occured querying Emby '
'to logoff user %s at %s.' % (self.user, self.host))
self.logger.debug('Socket Exception: %s' % str(e))
# Return; we're done
return False
# We logged our successfully if we reached here
# Reset our variables
self.access_token = None
self.user_id = None
return True
def notify(self, title, body, notify_type, **kwargs):
"""
Perform Emby Notification
"""
if not self.is_authenticated and not self.login():
# Authenticate if we aren't already
return False
# Acquire our list of sessions
sessions = self.sessions().keys()
if not sessions:
self.logger.warning('There were no Emby sessions to notify.')
# We don't need to fail; there really is no one to notify
return True
url = '%s://%s' % (self.schema, self.host)
if self.port:
url += ':%d' % self.port
# Append our remaining path
url += '/Sessions/%s/Message'
# Prepare Emby Object
payload = {
'Header': title,
'Text': body,
}
if not self.modal:
payload['TimeoutMs'] = self.emby_message_timeout_ms
headers = {
'User-Agent': self.app_id,
'Content-Type': 'application/json',
'X-Emby-Authorization': self.emby_auth_header,
'X-MediaBrowser-Token': self.access_token,
}
# Track whether or not we had a failure or not.
has_error = False
for session in sessions:
# Update our session
session_url = url % session
self.logger.debug('Emby POST URL: %s (cert_verify=%r)' % (
session_url, self.verify_certificate,
))
self.logger.debug('Emby Payload: %s' % str(payload))
try:
r = requests.post(
session_url,
data=dumps(payload),
headers=headers,
verify=self.verify_certificate,
)
if r.status_code not in (
requests.codes.ok,
requests.codes.no_content):
try:
self.logger.warning(
'Failed to send Emby notification: '
'%s (error=%s).' % (
HTTP_ERROR_MAP[r.status_code],
r.status_code))
except KeyError:
self.logger.warning(
'Failed to send Emby notification '
'(error=%s).' % (r.status_code))
# Mark our failure
has_error = True
continue
else:
self.logger.info('Sent Emby notification.')
except requests.RequestException as e:
self.logger.warning(
'A Connection error occured sending Emby '
'notification to %s.' % self.host)
self.logger.debug('Socket Exception: %s' % str(e))
# Mark our failure
has_error = True
continue
return not has_error
@property
def is_authenticated(self):
"""
Returns True if we're authenticated and False if not.
"""
return True if self.access_token and self.user_id else False
@property
def emby_auth_header(self):
"""
Generates the X-Emby-Authorization header response based on whether
we're authenticated or not.
"""
# Specific to Emby
header_args = [
('MediaBrowser Client', self.app_id),
('Device', self.app_id),
('DeviceId', self.emby_device_id),
('Version', str(VERSION)),
]
if self.user_id:
# Append UserId variable if we're authenticated
header_args.append(('UserId', self.user))
return ', '.join(['%s="%s"' % (k, v) for k, v in header_args])
@staticmethod
def parse_url(url):
"""
Parses the URL and returns enough arguments that can allow
us to substantiate this object.
"""
results = NotifyBase.parse_url(url)
if not results:
# We're done early
return results
# Assign Default Emby Port
if not results['port']:
results['port'] = NotifyEmby.emby_default_port
# Modal type popup (default False)
results['modal'] = parse_bool(results['qsd'].get('modal', False))
return results
def __del__(self):
"""
Deconstructor
"""
self.logout()