mirror of
https://github.com/morpheus65535/bazarr
synced 2024-12-29 02:55:52 +00:00
585 lines
18 KiB
Python
585 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()
|