plexpy/plexpy/notifiers.py
2024-11-16 15:19:56 -08:00

4705 lines
205 KiB
Python

# -*- coding: utf-8 -*-
# This file is part of Tautulli.
#
# Tautulli is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Tautulli 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
import base64
from collections import defaultdict
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import email.utils
import json
import os
import re
import smtplib
import subprocess
import sys
import threading
import time
from urllib.parse import urlencode
from urllib.parse import urlparse
import bleach
import paho.mqtt.client
import paho.mqtt.publish
import requests
from requests.auth import HTTPBasicAuth
try:
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
_CRYPTOGRAPHY = True
except ImportError:
_CRYPTOGRAPHY = False
import gntp.notifier
import facebook
import twitter
import plexpy
from plexpy import common
from plexpy import database
from plexpy import helpers
from plexpy import logger
from plexpy import mobile_app
from plexpy import pmsconnect
from plexpy import request
from plexpy import users
BROWSER_NOTIFIERS = {}
AGENT_IDS = {'growl': 0,
'prowl': 1,
'xbmc': 2,
'plex': 3,
'pushbullet': 6,
'pushover': 7,
'osx': 8,
'boxcar': 9,
'email': 10,
'twitter': 11,
'ifttt': 12,
'telegram': 13,
'slack': 14,
'scripts': 15,
'facebook': 16,
'browser': 17,
'join': 18,
'discord': 20,
'remoteapp': 21,
'groupme': 22,
'mqtt': 23,
'zapier': 24,
'webhook': 25,
'plexmobileapp': 26,
'lunasea': 27,
'microsoftteams': 28,
'gotify': 29,
'ntfy': 30
}
DEFAULT_CUSTOM_CONDITIONS = [{'parameter': '', 'operator': '', 'value': [], 'type': None}]
CUSTOM_CONDITION_TYPE_OPERATORS = {
'float': ['is', 'is not', 'is greater than', 'is less than'],
'int': ['is', 'is not', 'is greater than', 'is less than'],
'str': ['contains', 'does not contain', 'is', 'is not', 'begins with', 'does not begin with', 'ends with', 'does not end with'],
}
def available_notification_agents():
agents = [{'label': 'Tautulli Remote App',
'name': 'remoteapp',
'id': AGENT_IDS['remoteapp'],
'class': TAUTULLIREMOTEAPP,
'action_types': ('all',)
},
{'label': 'Boxcar',
'name': 'boxcar',
'id': AGENT_IDS['boxcar'],
'class': BOXCAR,
'action_types': ('all',)
},
{'label': 'Browser',
'name': 'browser',
'id': AGENT_IDS['browser'],
'class': BROWSER,
'action_types': ('all',)
},
{'label': 'Discord',
'name': 'discord',
'id': AGENT_IDS['discord'],
'class': DISCORD,
'action_types': ('all',)
},
{'label': 'Email',
'name': 'email',
'id': AGENT_IDS['email'],
'class': EMAIL,
'action_types': ('all',)
},
{'label': 'Facebook',
'name': 'facebook',
'id': AGENT_IDS['facebook'],
'class': FACEBOOK,
'action_types': ('all',)
},
{'label': 'Gotify',
'name': 'gotify',
'id': AGENT_IDS['gotify'],
'class': GOTIFY,
'action_types': ('all',)
},
{'label': 'GroupMe',
'name': 'groupme',
'id': AGENT_IDS['groupme'],
'class': GROUPME,
'action_types': ('all',)
},
{'label': 'Growl',
'name': 'growl',
'id': AGENT_IDS['growl'],
'class': GROWL,
'action_types': ('all',)
},
{'label': 'IFTTT',
'name': 'ifttt',
'id': AGENT_IDS['ifttt'],
'class': IFTTT,
'action_types': ('all',)
},
{'label': 'Join',
'name': 'join',
'id': AGENT_IDS['join'],
'class': JOIN,
'action_types': ('all',)
},
{'label': 'Kodi',
'name': 'xbmc',
'id': AGENT_IDS['xbmc'],
'class': XBMC,
'action_types': ('all',)
},
{'label': 'LunaSea',
'name': 'lunasea',
'id': AGENT_IDS['lunasea'],
'class': LUNASEA,
'action_types': ('all',)
},
{'label': 'Microsoft Teams',
'name': 'microsoftteams',
'id': AGENT_IDS['microsoftteams'],
'class': MICROSOFTTEAMS,
'action_types': ('all',)
},
{'label': 'MQTT',
'name': 'mqtt',
'id': AGENT_IDS['mqtt'],
'class': MQTT,
'action_types': ('all',)
},
{'label': 'ntfy',
'name': 'ntfy',
'id': AGENT_IDS['ntfy'],
'class': NTFY,
'action_types': ('all',)
},
{'label': 'Plex Home Theater',
'name': 'plex',
'id': AGENT_IDS['plex'],
'class': PLEX,
'action_types': ('all',)
},
{'label': 'Plex Android / iOS App',
'name': 'plexmobileapp',
'id': AGENT_IDS['plexmobileapp'],
'class': PLEXMOBILEAPP,
'action_types': ('on_play', 'on_created', 'on_newdevice')
},
{'label': 'Prowl',
'name': 'prowl',
'id': AGENT_IDS['prowl'],
'class': PROWL,
'action_types': ('all',)
},
{'label': 'Pushbullet',
'name': 'pushbullet',
'id': AGENT_IDS['pushbullet'],
'class': PUSHBULLET,
'action_types': ('all',)
},
{'label': 'Pushover',
'name': 'pushover',
'id': AGENT_IDS['pushover'],
'class': PUSHOVER,
'action_types': ('all',)
},
{'label': 'Script',
'name': 'scripts',
'id': AGENT_IDS['scripts'],
'class': SCRIPTS,
'action_types': ('all',)
},
{'label': 'Slack',
'name': 'slack',
'id': AGENT_IDS['slack'],
'class': SLACK,
'action_types': ('all',)
},
{'label': 'Telegram',
'name': 'telegram',
'id': AGENT_IDS['telegram'],
'class': TELEGRAM,
'action_types': ('all',)
},
{'label': 'Twitter',
'name': 'twitter',
'id': AGENT_IDS['twitter'],
'class': TWITTER,
'action_types': ('all',)
},
{'label': 'Webhook',
'name': 'webhook',
'id': AGENT_IDS['webhook'],
'class': WEBHOOK,
'action_types': ('all',)
},
{'label': 'Zapier',
'name': 'zapier',
'id': AGENT_IDS['zapier'],
'class': ZAPIER,
'action_types': ('all',)
}
]
# OSX Notifications should only be visible if it can be used
if OSX().validate():
agents.append({'label': 'macOS Notification Center',
'name': 'osx',
'id': AGENT_IDS['osx'],
'class': OSX,
'action_types': ('all',)
})
return agents
def available_notification_actions(agent_id=None):
actions = [{'label': 'Playback Start',
'name': 'on_play',
'description': 'Trigger a notification when a stream is started.',
'subject': 'Tautulli ({server_name})',
'body': '{user} ({player}) started playing {title}.',
'icon': 'fa-play',
'media_types': ('movie', 'episode', 'track')
},
{'label': 'Playback Stop',
'name': 'on_stop',
'description': 'Trigger a notification when a stream is stopped.',
'subject': 'Tautulli ({server_name})',
'body': '{user} ({player}) has stopped {title}.',
'icon': 'fa-stop',
'media_types': ('movie', 'episode', 'track')
},
{'label': 'Playback Pause',
'name': 'on_pause',
'description': 'Trigger a notification when a stream is paused.',
'subject': 'Tautulli ({server_name})',
'body': '{user} ({player}) has paused {title}.',
'icon': 'fa-pause',
'media_types': ('movie', 'episode', 'track')
},
{'label': 'Playback Resume',
'name': 'on_resume',
'description': 'Trigger a notification when a stream is resumed.',
'subject': 'Tautulli ({server_name})',
'body': '{user} ({player}) has resumed {title}.',
'icon': 'fa-play',
'media_types': ('movie', 'episode', 'track')
},
{'label': 'Playback Error',
'name': 'on_error',
'description': 'Trigger a notification when a stream encounters an error.',
'subject': 'Tautulli ({server_name})',
'body': '{user} ({player}) encountered an error trying to play {title}.',
'icon': 'fa-exclamation-triangle',
'media_types': ('movie', 'episode', 'track')
},
{'label': 'Transcode Decision Change',
'name': 'on_change',
'description': 'Trigger a notification when a stream changes transcode decision.',
'subject': 'Tautulli ({server_name})',
'body': '{user} ({player}) has changed transcode decision for {title}.',
'icon': 'fa-exchange-alt',
'media_types': ('movie', 'episode', 'track')
},
{'label': 'Intro Marker',
'name': 'on_intro',
'description': 'Trigger a notification when a video stream reaches any intro marker.',
'subject': 'Tautulli ({server_name})',
'body': '{user} ({player}) has reached an intro marker for {title}.',
'icon': 'fa-bookmark',
'media_types': ('episode',)
},
{'label': 'Commercial Marker',
'name': 'on_commercial',
'description': 'Trigger a notification when a video stream reaches any commercial marker.',
'subject': 'Tautulli ({server_name})',
'body': '{user} ({player}) has reached a commercial marker for {title}.',
'icon': 'fa-bookmark',
'media_types': ('movie', 'episode')
},
{'label': 'Credits Marker',
'name': 'on_credits',
'description': 'Trigger a notification when a video stream reaches any credits marker.',
'subject': 'Tautulli ({server_name})',
'body': '{user} ({player}) has reached a credits marker for {title}.',
'icon': 'fa-bookmark',
'media_types': ('movie', 'episode')
},
{'label': 'Watched',
'name': 'on_watched',
'description': 'Trigger a notification when a stream reaches the specified watched or listened threshold.',
'subject': 'Tautulli ({server_name})',
'body': '{user} ({player}) has watched {title}.',
'icon': 'fa-eye',
'media_types': ('movie', 'episode', 'track')
},
{'label': 'Buffer Warning',
'name': 'on_buffer',
'description': 'Trigger a notification when a stream exceeds the specified buffer threshold.',
'subject': 'Tautulli ({server_name})',
'body': '{user} ({player}) is buffering {title}.',
'icon': 'fa-spinner',
'media_types': ('movie', 'episode', 'track')
},
{'label': 'User Concurrent Streams',
'name': 'on_concurrent',
'description': 'Trigger a notification when a user exceeds the concurrent stream threshold.',
'subject': 'Tautulli ({server_name})',
'body': '{user} has {user_streams} concurrent streams.',
'icon': 'fa-arrow-circle-o-right',
'media_types': ('movie', 'episode', 'track')
},
{'label': 'User New Device',
'name': 'on_newdevice',
'description': 'Trigger a notification when a user streams from a new device.',
'subject': 'Tautulli ({server_name})',
'body': '{user} is streaming from a new device: {player}.',
'icon': 'fa-desktop',
'media_types': ('movie', 'episode', 'track')
},
{'label': 'Recently Added',
'name': 'on_created',
'description': 'Trigger a notification when a media item is added to the Plex Media Server.',
'subject': 'Tautulli ({server_name})',
'body': '{title} was recently added to Plex.',
'icon': 'fa-download',
'media_types': ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track')
},
{'label': 'Plex Server Down',
'name': 'on_intdown',
'description': 'Trigger a notification when the Plex Media Server cannot be reached internally.',
'subject': 'Tautulli ({server_name})',
'body': 'The Plex Media Server is down.',
'icon': 'fa-server',
'media_types': ('server',)
},
{'label': 'Plex Server Back Up',
'name': 'on_intup',
'description': 'Trigger a notification when the Plex Media Server can be reached internally after being down.',
'subject': 'Tautulli ({server_name})',
'body': 'The Plex Media Server is back up.',
'icon': 'fa-server',
'media_types': ('server',)
},
{'label': 'Plex Remote Access Down',
'name': 'on_extdown',
'description': 'Trigger a notification when the Plex Media Server cannot be reached externally.',
'subject': 'Tautulli ({server_name})',
'body': 'The Plex Media Server remote access is down. ({remote_access_reason})',
'icon': 'fa-server',
'media_types': ('server',)
},
{'label': 'Plex Remote Access Back Up',
'name': 'on_extup',
'description': 'Trigger a notification when the Plex Media Server can be reached externally after being down.',
'subject': 'Tautulli ({server_name})',
'body': 'The Plex Media Server remote access is back up.',
'icon': 'fa-server',
'media_types': ('server',)
},
{'label': 'Plex Update Available',
'name': 'on_pmsupdate',
'description': 'Trigger a notification when an update for the Plex Media Server is available.',
'subject': 'Tautulli ({server_name})',
'body': 'An update is available for the Plex Media Server (version {update_version}).',
'icon': 'fa-refresh',
'media_types': ('server',)
},
{'label': 'Tautulli Update Available',
'name': 'on_plexpyupdate',
'description': 'Trigger a notification when an update for the Tautulli is available.',
'subject': 'Tautulli ({server_name})',
'body': 'An update is available for Tautulli (version {tautulli_update_version}).',
'icon': 'fa-refresh',
'media_types': ('server',)
},
{'label': 'Tautulli Database Corruption',
'name': 'on_plexpydbcorrupt',
'description': 'Trigger a notification if Tautulli database corruption is detected when backing up the database.',
'subject': 'Tautulli ({server_name})',
'body': 'Tautulli database corruption detected. Automatic cleanup of database backups is suspended.',
'icon': 'fa-database',
'media_types': ('server',)
}
]
if str(agent_id).isdigit():
action_types = get_notify_agents(return_dict=True).get(int(agent_id), {}).get('action_types', [])
if 'all' not in action_types:
actions = [a for a in actions if a['name'] in action_types]
return actions
def get_agent_class(agent_id=None, config=None):
if str(agent_id).isdigit():
agent = get_notify_agents(return_dict=True).get(int(agent_id), {}).get('class', Notifier)
return agent(config=config)
else:
return None
def get_notify_agents(return_dict=False):
if return_dict:
return {a['id']: a for a in available_notification_agents()}
return tuple(a['name'] for a in sorted(available_notification_agents(), key=lambda k: k['label']))
def get_notify_actions(return_dict=False):
if return_dict:
return {a['name']: a for a in available_notification_actions()}
return tuple(a['name'] for a in available_notification_actions())
def get_notifiers(notifier_id=None, notify_action=None):
notify_actions = get_notify_actions()
where = where_id = where_action = ''
args = []
if notifier_id or notify_action:
where = 'WHERE '
if notifier_id:
where_id += 'notifiers.id = ?'
args.append(notifier_id)
if notify_action and notify_action in notify_actions:
where_action = '%s = ?' % notify_action
args.append(1)
where += ' AND '.join([w for w in [where_id, where_action] if w])
db = database.MonitorDatabase()
result = db.select(
(
"SELECT notifiers.id, notifiers.agent_id, notifiers.agent_name, notifiers.agent_label, notifiers.friendly_name, %s, "
"MAX(notify_log.timestamp) AS last_triggered, notify_log.success AS last_success "
"FROM notifiers "
"LEFT OUTER JOIN notify_log ON notifiers.id = notify_log.notifier_id "
"%s "
"GROUP BY notifiers.id"
) % (', '.join(notify_actions), where), args=args
)
for item in result:
item['active'] = int(any([item.pop(k) for k in list(item.keys()) if k in notify_actions]))
return result
def delete_notifier(notifier_id=None):
db = database.MonitorDatabase()
if str(notifier_id).isdigit():
logger.debug("Tautulli Notifiers :: Deleting notifier_id %s from the database."
% notifier_id)
result = db.action("DELETE FROM notifiers WHERE id = ?", args=[notifier_id])
return True
else:
return False
def get_notifier_config(notifier_id=None, mask_passwords=False):
if str(notifier_id).isdigit():
notifier_id = int(notifier_id)
else:
logger.error("Tautulli Notifiers :: Unable to retrieve notifier config: invalid notifier_id %s."
% notifier_id)
return None
db = database.MonitorDatabase()
result = db.select_single("SELECT * FROM notifiers WHERE id = ?", args=[notifier_id])
if not result:
return None
try:
config = json.loads(result.pop('notifier_config', '{}'))
notifier_agent = get_agent_class(agent_id=result['agent_id'], config=config)
except Exception as e:
logger.error("Tautulli Notifiers :: Failed to get notifier config options: %s." % e)
return
if mask_passwords:
notifier_agent.config = helpers.mask_config_passwords(notifier_agent.config)
notify_actions = get_notify_actions(return_dict=True)
notifier_actions = {}
notifier_text = {}
for k in list(result.keys()):
if k in notify_actions:
subject = result.pop(k + '_subject')
body = result.pop(k + '_body')
if subject is None:
subject = "" if result['agent_name'] in ('scripts', 'webhook') else notify_actions[k]['subject']
if body is None:
body = "" if result['agent_name'] in ('scripts', 'webhook') else notify_actions[k]['body']
notifier_actions[k] = helpers.cast_to_int(result.pop(k))
notifier_text[k] = {'subject': subject,
'body': body}
try:
result['custom_conditions'] = json.loads(result['custom_conditions'])
except (ValueError, TypeError):
result['custom_conditions'] = DEFAULT_CUSTOM_CONDITIONS
if not result['custom_conditions_logic']:
result['custom_conditions_logic'] = ''
result['config'] = notifier_agent.config
result['config_options'] = notifier_agent.return_config_options(mask_passwords=mask_passwords)
result['actions'] = notifier_actions
result['notify_text'] = notifier_text
return result
def add_notifier_config(agent_id=None, **kwargs):
if str(agent_id).isdigit():
agent_id = int(agent_id)
else:
logger.error("Tautulli Notifiers :: Unable to add new notifier: invalid agent_id %s."
% agent_id)
return False
agent = get_notify_agents(return_dict=True).get(agent_id, None)
if not agent:
logger.error("Tautulli Notifiers :: Unable to retrieve new notification agent: invalid agent_id %s."
% agent_id)
return False
agent_class = get_agent_class(agent_id=agent['id'])
keys = {'id': None}
values = {'agent_id': agent['id'],
'agent_name': agent['name'],
'agent_label': agent['label'],
'friendly_name': '',
'notifier_config': json.dumps(agent_class.config),
'custom_conditions': json.dumps(DEFAULT_CUSTOM_CONDITIONS),
'custom_conditions_logic': ''
}
if agent['name'] in ('scripts', 'webhook'):
for a in available_notification_actions():
values[a['name'] + '_subject'] = ''
values[a['name'] + '_body'] = ''
else:
for a in available_notification_actions():
values[a['name'] + '_subject'] = a['subject']
values[a['name'] + '_body'] = a['body']
db = database.MonitorDatabase()
try:
db.upsert(table_name='notifiers', key_dict=keys, value_dict=values)
notifier_id = db.last_insert_id()
logger.info("Tautulli Notifiers :: Added new notification agent: %s (notifier_id %s)."
% (agent['label'], notifier_id))
blacklist_logger()
return notifier_id
except Exception as e:
logger.warn("Tautulli Notifiers :: Unable to add notification agent: %s." % e)
return False
def set_notifier_config(notifier_id=None, agent_id=None, **kwargs):
if str(agent_id).isdigit():
agent_id = int(agent_id)
else:
logger.error("Tautulli Notifiers :: Unable to set existing notifier: invalid agent_id %s."
% agent_id)
return False
agent = get_notify_agents(return_dict=True).get(agent_id, None)
if not agent:
logger.error("Tautulli Notifiers :: Unable to retrieve existing notification agent: invalid agent_id %s."
% agent_id)
return False
notify_actions = get_notify_actions()
config_prefix = agent['name'] + '_'
actions = {k: helpers.cast_to_int(kwargs.pop(k))
for k in list(kwargs.keys()) if k in notify_actions}
subject_text = {k: kwargs.pop(k)
for k in list(kwargs.keys()) if k.startswith(notify_actions) and k.endswith('_subject')}
body_text = {k: kwargs.pop(k)
for k in list(kwargs.keys()) if k.startswith(notify_actions) and k.endswith('_body')}
notifier_config = {k[len(config_prefix):]: kwargs.pop(k)
for k in list(kwargs.keys()) if k.startswith(config_prefix)}
for cfg, val in notifier_config.items():
# Check for a password config keys and a blank password from the HTML form
if 'password' in cfg and val == ' ':
# Get the previous password so we don't overwrite it with a blank value
old_notifier_config = get_notifier_config(notifier_id=notifier_id)
notifier_config[cfg] = old_notifier_config['config'][cfg]
agent_class = get_agent_class(agent_id=agent['id'], config=notifier_config)
custom_conditions = validate_conditions(kwargs.get('custom_conditions'))
if custom_conditions is False:
logger.error("Tautulli Notifiers :: Unable to update notification agent: Invalid custom conditions.")
return False
keys = {'id': notifier_id}
values = {'agent_id': agent['id'],
'agent_name': agent['name'],
'agent_label': agent['label'],
'friendly_name': kwargs.get('friendly_name', ''),
'notifier_config': json.dumps(agent_class.config),
'custom_conditions': json.dumps(custom_conditions or DEFAULT_CUSTOM_CONDITIONS),
'custom_conditions_logic': kwargs.get('custom_conditions_logic', ''),
}
values.update(actions)
values.update(subject_text)
values.update(body_text)
db = database.MonitorDatabase()
try:
db.upsert(table_name='notifiers', key_dict=keys, value_dict=values)
logger.info("Tautulli Notifiers :: Updated notification agent: %s (notifier_id %s)."
% (agent['label'], notifier_id))
blacklist_logger()
if agent['name'] == 'browser':
check_browser_enabled()
return True
except Exception as e:
logger.warn("Tautulli Notifiers :: Unable to update notification agent: %s." % e)
return False
def send_notification(notifier_id=None, subject='', body='', notify_action='', notification_id=None, **kwargs):
notifier_config = get_notifier_config(notifier_id=notifier_id)
if notifier_config:
agent = get_agent_class(agent_id=notifier_config['agent_id'],
config=notifier_config['config'])
return agent.notify(subject=subject,
body=body,
action=notify_action.split('on_')[-1],
notification_id=notification_id,
**kwargs)
else:
logger.debug("Tautulli Notifiers :: Notification requested but no notifier_id received.")
def validate_conditions(custom_conditions):
if custom_conditions is None:
return DEFAULT_CUSTOM_CONDITIONS
try:
conditions = json.loads(custom_conditions)
except ValueError:
logger.error("Tautulli Notifiers :: Unable to parse custom conditions json: %s" % custom_conditions)
return False
if not isinstance(conditions, list):
logger.error("Tautulli Notifiers :: Invalid custom conditions: %s. Conditions must be a list." % conditions)
return False
validated_conditions = []
for condition in conditions:
validated_condition = DEFAULT_CUSTOM_CONDITIONS[0].copy()
if not isinstance(condition, dict):
logger.error("Tautulli Notifiers :: Invalid custom condition: %s. Condition must be a dict." % condition)
return False
parameter = str(condition.get('parameter', '')).lower()
operator = str(condition.get('operator', '')).lower()
values = condition.get('value', [])
if parameter:
parameter_type = common.NOTIFICATION_PARAMETERS_TYPES.get(parameter)
if not parameter_type:
logger.error("Tautulli Notifiers :: Invalid parameter '%s' in custom condition: %s" % (parameter, condition))
return False
validated_condition['parameter'] = parameter.lower()
validated_condition['type'] = parameter_type
if operator:
if operator not in CUSTOM_CONDITION_TYPE_OPERATORS.get(parameter_type, []):
logger.error("Tautulli Notifiers :: Invalid operator '%s' for parameter '%s' in custom condition: %s" % (operator, parameter, condition))
return False
validated_condition['operator'] = operator
if values:
if not isinstance(values, list):
values = [values]
for value in values:
if not isinstance(value, (str, int, float)):
logger.error("Tautulli Notifiers :: Invalid value '%s' for parameter '%s' in custom condition: %s" % (value, parameter, condition))
return False
validated_condition['value'] = values
validated_conditions.append(validated_condition)
return validated_conditions
def blacklist_logger():
db = database.MonitorDatabase()
notifiers = db.select('SELECT notifier_config FROM notifiers')
for n in notifiers:
config = json.loads(n['notifier_config'] or '{}')
logger.blacklist_config(config)
class PrettyMetadata(object):
def __init__(self, parameters=None):
self.parameters = parameters or {}
self.media_type = self.parameters.get('media_type')
@staticmethod
def get_movie_providers():
return {'': '',
'plexweb': 'Plex Web',
'imdb': 'IMDB',
'themoviedb': 'The Movie Database',
'thetvdb': 'TheTVDB',
'trakt': 'Trakt.tv'
}
@staticmethod
def get_tv_providers():
return {'': '',
'plexweb': 'Plex Web',
'imdb': 'IMDB',
'themoviedb': 'The Movie Database',
'thetvdb': 'TheTVDB',
'tvmaze': 'TVmaze',
'trakt': 'Trakt.tv'
}
@staticmethod
def get_music_providers():
return {'': '',
'plexweb': 'Plex Web',
'lastfm': 'Last.fm',
'musicbrainz': 'MusicBrainz'
}
def get_poster_url(self):
poster_url = self.parameters.get('poster_url')
if not poster_url:
if self.media_type in ('artist', 'album', 'track'):
poster_url = common.ONLINE_COVER_THUMB
else:
poster_url = common.ONLINE_POSTER_THUMB
return poster_url
def get_provider_name(self, provider):
provider_name = ''
if provider == 'plexweb':
provider_name = 'Plex Web'
elif provider == 'imdb':
provider_name = 'IMDb'
elif provider == 'thetvdb':
provider_name = 'TheTVDB'
elif provider == 'themoviedb':
provider_name = 'The Movie Database'
elif provider == 'tvmaze':
provider_name = 'TVmaze'
elif provider == 'trakt':
provider_name = 'Trakt.tv'
elif provider == 'lastfm':
provider_name = 'Last.fm'
elif provider == 'musicbrainz':
provider_name = 'MusicBrainz'
# else:
# if self.media_type == 'movie':
# provider_name = 'IMDb'
# elif self.media_type in ('show', 'season', 'episode'):
# provider_name = 'TheTVDB'
# elif self.media_type in ('artist', 'album', 'track'):
# provider_name = 'Last.fm'
return provider_name
def get_provider_link(self, provider=None):
provider_link = ''
if provider == 'plexweb':
provider_link = self.get_plex_url()
elif provider:
provider_link = self.parameters.get(provider + '_url', '')
# else:
# if self.media_type == 'movie':
# provider_link = self.parameters.get('imdb_url', '')
# elif self.media_type in ('show', 'season', 'episode'):
# provider_link = self.parameters.get('thetvdb_url', '')
# elif self.media_type in ('artist', 'album', 'track'):
# provider_link = self.parameters.get('lastfm_url', '')
return provider_link
def get_caption(self, provider):
provider_name = self.get_provider_name(provider)
return 'View on ' + provider_name
def get_title(self, divider='-'):
title = ''
if self.media_type == 'movie':
title = '%s (%s)' % (self.parameters['title'], self.parameters['year'])
elif self.media_type == 'show':
title = '%s (%s)' % (self.parameters['show_name'], self.parameters['year'])
elif self.media_type == 'season':
title = '%s - %s' % (self.parameters['show_name'], self.parameters['season_name'])
elif self.media_type == 'episode':
season = helpers.short_season(self.parameters['season_name'])
title = '%s - %s (%s %s E%s)' % (self.parameters['show_name'],
self.parameters['episode_name'],
season,
divider,
self.parameters['episode_num'])
elif self.media_type == 'artist':
title = self.parameters['artist_name']
elif self.media_type == 'album':
title = '%s - %s' % (self.parameters['artist_name'], self.parameters['album_name'])
elif self.media_type == 'track':
title = '%s - %s' % (self.parameters['track_name'], self.parameters['track_artist'])
return title
def get_description(self):
if self.media_type == 'track':
description = self.parameters['album_name']
else:
description = self.parameters['summary']
return description
def get_plex_url(self):
return self.parameters['plex_url']
@staticmethod
def get_parameters():
parameters = {param['value']: param['name']
for category in common.NOTIFICATION_PARAMETERS for param in category['parameters']}
parameters[''] = ''
return parameters
def get_image(self):
result = pmsconnect.PmsConnect().get_image(img=self.parameters.get('poster_thumb', ''))
if result and result[0]:
poster_content = result[0]
poster_filename = 'poster_{}.png'.format(self.parameters['rating_key'])
return (poster_filename, poster_content, 'image/png')
logger.error("Tautulli Notifiers :: Unable to retrieve image for notification.")
class Notifier(object):
NAME = ''
_DEFAULT_CONFIG = {}
def __init__(self, config=None):
self.config = self.set_config(config=config, default=self._DEFAULT_CONFIG)
def set_config(self, config=None, default=None):
return self._validate_config(config=config, default=default)
def _validate_config(self, config=None, default=None):
if config is None:
return default
new_config = {}
for k, v in default.items():
if isinstance(v, int):
new_config[k] = helpers.cast_to_int(config.get(k, v))
elif isinstance(v, list):
c = config.get(k, v)
if not isinstance(c, list):
new_config[k] = [c]
else:
new_config[k] = c
else:
new_config[k] = config.get(k, v)
return new_config
def return_default_config(self):
return self._DEFAULT_CONFIG.copy()
def notify(self, subject='', body='', action='', **kwargs):
if self.NAME not in ('Script', 'Webhook'):
if not subject and self.config.get('incl_subject', True):
logger.error("Tautulli Notifiers :: %s notification subject cannot be blank." % self.NAME)
return
elif not body:
logger.error("Tautulli Notifiers :: %s notification body cannot be blank." % self.NAME)
return
return self.agent_notify(subject=subject, body=body, action=action, **kwargs)
def agent_notify(self, subject='', body='', action='', **kwargs):
pass
def make_request(self, url, method='POST', **kwargs):
logger.info("Tautulli Notifiers :: Sending {name} notification...".format(name=self.NAME))
response, err_msg, req_msg = request.request_response2(url, method, **kwargs)
if response and not err_msg:
logger.info("Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
return True
else:
verify_msg = ""
if response is not None and 400 <= response.status_code < 500:
verify_msg = " Verify your notification agent settings are correct."
logger.error("Tautulli Notifiers :: {name} notification failed.{msg}".format(msg=verify_msg, name=self.NAME))
if err_msg:
logger.error("Tautulli Notifiers :: {}".format(err_msg))
if req_msg:
logger.debug("Tautulli Notifiers :: Request response: {}".format(req_msg))
return False
def return_config_options(self, mask_passwords=False):
config_options = self._return_config_options()
# Mask password config options
if mask_passwords:
helpers.mask_config_passwords(config_options)
return config_options
def _return_config_options(self):
config_options = []
return config_options
class BOXCAR(Notifier):
"""
Boxcar notifications
"""
NAME = 'Boxcar'
_DEFAULT_CONFIG = {'token': '',
'sound': ''
}
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'user_credentials': self.config['token'],
'notification[title]': subject,
'notification[long_message]': body,
'notification[sound]': self.config['sound']
}
return self.make_request('https://new.boxcar.io/api/notifications', params=data)
def get_sounds(self):
sounds = {'': '',
'beep-crisp': 'Beep (Crisp)',
'beep-soft': 'Beep (Soft)',
'bell-modern': 'Bell (Modern)',
'bell-one-tone': 'Bell (One Tone)',
'bell-simple': 'Bell (Simple)',
'bell-triple': 'Bell (Triple)',
'bird-1': 'Bird (1)',
'bird-2': 'Bird (2)',
'boing': 'Boing',
'cash': 'Cash',
'clanging': 'Clanging',
'detonator-charge': 'Detonator Charge',
'digital-alarm': 'Digital Alarm',
'done': 'Done',
'echo': 'Echo',
'flourish': 'Flourish',
'harp': 'Harp',
'light': 'Light',
'magic-chime':'Magic Chime',
'magic-coin': 'Magic Coin',
'no-sound': 'No Sound',
'notifier-1': 'Notifier (1)',
'notifier-2': 'Notifier (2)',
'notifier-3': 'Notifier (3)',
'orchestral-long': 'Orchestral (Long)',
'orchestral-short': 'Orchestral (Short)',
'score': 'Score',
'success': 'Success',
'up': 'Up'}
return sounds
def _return_config_options(self):
config_option = [{'label': 'Boxcar Access Token',
'value': self.config['token'],
'name': 'boxcar_token',
'description': 'Your Boxcar access token.',
'input_type': 'token'
},
{'label': 'Sound',
'value': self.config['sound'],
'name': 'boxcar_sound',
'description': 'Set the notification sound. Leave blank for the default sound.',
'input_type': 'select',
'select_options': self.get_sounds()
}
]
return config_option
class BROWSER(Notifier):
"""
Browser notifications
"""
NAME = 'Browser'
_DEFAULT_CONFIG = {'auto_hide_delay': 5
}
def agent_notify(self, subject='', body='', action='', **kwargs):
logger.info("Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
return True
def _return_config_options(self):
config_option = [{'label': 'Note',
'description': 'You may need to refresh the page after saving for changes to take effect.',
'input_type': 'help'
},
{'label': 'Allow Notifications',
'value': 'Allow Notifications',
'name': 'browser_allow_browser',
'description': 'Click to allow browser notifications. '
'You must click this button for each browser.',
'input_type': 'button'
},
{'label': 'Auto Hide Delay',
'value': self.config['auto_hide_delay'],
'name': 'browser_auto_hide_delay',
'description': 'Set the number of seconds for the notification to remain visible. '
'Set 0 to disable auto hiding. (Note: Some browsers have a maximum time limit.)',
'input_type': 'number'
}
]
return config_option
class DISCORD(Notifier):
"""
Discord Notifications
"""
NAME = 'Discord'
_DEFAULT_CONFIG = {'hook': '',
'username': '',
'avatar_url': '',
'color': '',
'tts': 0,
'incl_subject': 1,
'incl_card': 0,
'incl_description': 1,
'incl_thumbnail': 0,
'incl_pmslink': 0,
'movie_provider': '',
'tv_provider': '',
'music_provider': ''
}
def agent_notify(self, subject='', body='', action='', **kwargs):
if self.config['incl_subject']:
text = subject + '\r\n' + body
else:
text = body
data = {'content': text}
if self.config['username']:
data['username'] = self.config['username']
if self.config['avatar_url']:
data['avatar_url'] = self.config['avatar_url']
if self.config['tts']:
data['tts'] = True
files = {}
if self.config['incl_card'] and kwargs.get('parameters', {}).get('media_type'):
# Grab formatted metadata
pretty_metadata = PrettyMetadata(kwargs['parameters'])
image = pretty_metadata.get_image()
if image:
files = {'files[0]': image}
if pretty_metadata.media_type == 'movie':
provider = self.config['movie_provider']
elif pretty_metadata.media_type in ('show', 'season', 'episode'):
provider = self.config['tv_provider']
elif pretty_metadata.media_type in ('artist', 'album', 'track'):
provider = self.config['music_provider']
else:
provider = None
poster_url = pretty_metadata.get_poster_url()
provider_name = pretty_metadata.get_provider_name(provider)
provider_link = pretty_metadata.get_provider_link(provider)
title = pretty_metadata.get_title('\u00B7')
description = pretty_metadata.get_description()
plex_url = pretty_metadata.get_plex_url()
# Build Discord post attachment
attachment = {'title': title,
'timestamp': pretty_metadata.parameters['utctime']
}
if self.config['color']:
hex_match = re.match(r'^#([0-9a-fA-F]{3}){1,2}$', self.config['color'])
if hex_match:
hex = hex_match.group(0).lstrip('#')
hex = ''.join(h * 2 for h in hex) if len(hex) == 3 else hex
attachment['color'] = helpers.hex_to_int(hex)
if self.config['incl_thumbnail']:
attachment['thumbnail'] = {'url': 'attachment://{}'.format(image[0]) if image else poster_url}
else:
attachment['image'] = {'url': 'attachment://{}'.format(image[0]) if image else poster_url}
if self.config['incl_description']:
attachment['description'] = description[:2045] + (description[2045:] and '...')
fields = []
if provider_link:
attachment['url'] = provider_link
fields.append({'name': 'View Details',
'value': '[%s](%s)' % (provider_name, provider_link),
'inline': True})
if self.config['incl_pmslink']:
fields.append({'name': 'View Details',
'value': '[Plex Web](%s)' % plex_url,
'inline': True})
if fields:
attachment['fields'] = fields
data['embeds'] = [attachment]
params = {'wait': True}
if files:
files['payload_json'] = (None, json.dumps(data), 'application/json')
return self.make_request(self.config['hook'], params=params, files=files)
else:
return self.make_request(self.config['hook'], params=params, json=data)
def _return_config_options(self):
config_option = [{'label': 'Discord Webhook URL',
'value': self.config['hook'],
'name': 'discord_hook',
'description': 'Your Discord incoming webhook URL.',
'input_type': 'token'
},
{'label': 'Discord Username',
'value': self.config['username'],
'name': 'discord_username',
'description': 'The Discord username which will be used. Leave blank for webhook integration default.',
'input_type': 'text'
},
{'label': 'Discord Avatar',
'value': self.config['avatar_url'],
'description': 'The image url for the avatar which will be used. Leave blank for webhook integration default.',
'name': 'discord_avatar_url',
'input_type': 'text'
},
{'label': 'Discord Color',
'value': self.config['color'],
'description': 'The hex color value (starting with \'#\') for the border along the left side of the message attachment.',
'name': 'discord_color',
'input_type': 'text'
},
{'label': 'TTS',
'value': self.config['tts'],
'name': 'discord_tts',
'description': 'Send the notification using text-to-speech.',
'input_type': 'checkbox'
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'discord_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Rich Metadata Info',
'value': self.config['incl_card'],
'name': 'discord_incl_card',
'description': 'Include an info card with a poster and metadata with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Summary',
'value': self.config['incl_description'],
'name': 'discord_incl_description',
'description': 'Include a summary for the media on the info card.',
'input_type': 'checkbox'
},
{'label': 'Include Link to Plex Web',
'value': self.config['incl_pmslink'],
'name': 'discord_incl_pmslink',
'description': 'Include a second link to the media in Plex Web on the info card.',
'input_type': 'checkbox'
},
{'label': 'Use Poster Thumbnail',
'value': self.config['incl_thumbnail'],
'name': 'discord_incl_thumbnail',
'description': 'Use a thumbnail instead of a full sized poster on the info card.',
'input_type': 'checkbox'
},
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'discord_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers()
},
{'label': 'TV Show Link Source',
'value': self.config['tv_provider'],
'name': 'discord_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers()
},
{'label': 'Music Link Source',
'value': self.config['music_provider'],
'name': 'discord_music_provider',
'description': 'Select the source for music links on the info cards. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers()
}
]
return config_option
class EMAIL(Notifier):
"""
Email notifications
"""
NAME = 'Email'
_DEFAULT_CONFIG = {'from_name': 'Tautulli',
'from': '',
'to': [],
'cc': [],
'bcc': [],
'smtp_server': '',
'smtp_port': 465,
'smtp_user': '',
'smtp_password': '',
'tls': 2,
'html_support': 1
}
def agent_notify(self, subject='', body='', action='', **kwargs):
if not self.config['smtp_server']:
logger.error("Tautulli Notifiers :: %s notification failed: %s",
self.NAME, "Missing SMTP server")
return False
if self.config['html_support']:
plain = MIMEText(None, 'plain', 'utf-8')
plain.replace_header('Content-Transfer-Encoding', 'quoted-printable')
plain.set_payload(kwargs.get('plaintext', bleach.clean(body, strip=True)), 'utf-8')
html = MIMEText(body, 'html', 'utf-8')
msg = MIMEMultipart('alternative')
msg.attach(plain)
msg.attach(html)
else:
msg = MIMEText(None, 'plain', 'utf-8')
msg.replace_header('Content-Transfer-Encoding', 'quoted-printable')
msg.set_payload(body, 'utf-8')
msg_id = kwargs.get('msg_id', email.utils.make_msgid())
reply_msg_id = kwargs.get('reply_msg_id')
msg['Message-ID'] = msg_id
msg['Date'] = email.utils.formatdate(localtime=True)
msg['Subject'] = subject
msg['From'] = email.utils.formataddr((self.config['from_name'], self.config['from']))
msg['To'] = ','.join(self.config['to'])
msg['CC'] = ','.join(self.config['cc'])
if reply_msg_id:
msg["In-Reply-To"] = reply_msg_id
msg["References"] = reply_msg_id
recipients = self.config['to'] + self.config['cc'] + self.config['bcc']
mailserver = None
success = False
try:
if self.config['tls'] == 2:
mailserver = smtplib.SMTP_SSL(self.config['smtp_server'], self.config['smtp_port'])
else:
mailserver = smtplib.SMTP(self.config['smtp_server'], self.config['smtp_port'])
mailserver.ehlo()
if self.config['tls'] == 1:
mailserver.starttls()
mailserver.ehlo()
if self.config['smtp_user']:
mailserver.login(str(self.config['smtp_user']), str(self.config['smtp_password']))
mailserver.sendmail(self.config['from'], recipients, msg.as_string())
logger.info("Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
success = True
except Exception as e:
logger.error("Tautulli Notifiers :: %s notification failed: %s", self.NAME, e)
finally:
if mailserver:
mailserver.quit()
return success
def get_user_emails(self):
emails = {u['email']: u['friendly_name'] for u in users.Users().get_users() if u['email']}
user_emails_to = {v: '' for v in self.config['to']}
user_emails_cc = {v: '' for v in self.config['cc']}
user_emails_bcc = {v: '' for v in self.config['bcc']}
user_emails_to.update(emails)
user_emails_cc.update(emails)
user_emails_bcc.update(emails)
user_emails_to = [{'value': k, 'text': v} for k, v in user_emails_to.items()]
user_emails_cc = [{'value': k, 'text': v} for k, v in user_emails_cc.items()]
user_emails_bcc = [{'value': k, 'text': v} for k, v in user_emails_bcc.items()]
return user_emails_to, user_emails_cc, user_emails_bcc
def _return_config_options(self):
user_emails_to, user_emails_cc, user_emails_bcc = self.get_user_emails()
config_option = [{'label': 'From Name',
'value': self.config['from_name'],
'name': 'email_from_name',
'description': 'The name of the sender.',
'input_type': 'text'
},
{'label': 'From',
'value': self.config['from'],
'name': 'email_from',
'description': 'The email address of the sender.',
'input_type': 'text'
},
{'label': 'To',
'value': self.config['to'],
'name': 'email_to',
'description': 'The email address(es) of the recipients.',
'input_type': 'selectize',
'select_options': user_emails_to,
'select_all': True
},
{'label': 'CC',
'value': self.config['cc'],
'name': 'email_cc',
'description': 'The email address(es) to CC.',
'input_type': 'selectize',
'select_options': user_emails_cc,
'select_all': True
},
{'label': 'BCC',
'value': self.config['bcc'],
'name': 'email_bcc',
'description': 'The email address(es) to BCC.',
'input_type': 'selectize',
'select_options': user_emails_bcc,
'select_all': True
},
{'label': 'SMTP Server',
'value': self.config['smtp_server'],
'name': 'email_smtp_server',
'description': 'Host for the SMTP server.',
'input_type': 'text'
},
{'label': 'SMTP Port',
'value': self.config['smtp_port'],
'name': 'email_smtp_port',
'description': 'Port for the SMTP server.',
'input_type': 'number'
},
{'label': 'SMTP Username',
'value': self.config['smtp_user'],
'name': 'email_smtp_user',
'description': 'Username for the SMTP server.',
'input_type': 'text'
},
{'label': 'SMTP Password',
'value': self.config['smtp_password'],
'name': 'email_smtp_password',
'description': 'Password for the SMTP server.',
'input_type': 'password'
},
{'label': 'Encryption',
'value': self.config['tls'],
'name': 'email_tls',
'description': 'Send emails encrypted using SSL or TLS.',
'input_type': 'select',
'select_options': {0: 'None',
1: 'TLS/STARTTLS (Typically port 587)',
2: 'SSL/TLS (Typically port 465)'}
},
{'label': 'Enable HTML Support',
'value': self.config['html_support'],
'name': 'email_html_support',
'description': 'Style your messages using HTML tags.',
'input_type': 'checkbox'
}
]
return config_option
class FACEBOOK(Notifier):
"""
Facebook notifications
"""
NAME = 'Facebook'
_DEFAULT_CONFIG = {'redirect_uri': '',
'access_token': '',
'app_id': '',
'app_secret': '',
'group_id': '',
'incl_subject': 1,
'incl_card': 0,
'movie_provider': '',
'tv_provider': '',
'music_provider': ''
}
def _get_authorization(self, app_id='', app_secret='', redirect_uri=''):
# Temporarily store settings in the config so we can retrieve them in Facebook step 2.
# Assume the user won't be requesting authorization for multiple Facebook notifiers at the same time.
plexpy.CONFIG.FACEBOOK_APP_ID = app_id
plexpy.CONFIG.FACEBOOK_APP_SECRET = app_secret
plexpy.CONFIG.FACEBOOK_REDIRECT_URI = redirect_uri
plexpy.CONFIG.FACEBOOK_TOKEN = 'temp'
return facebook.auth_url(app_id=app_id,
canvas_url=redirect_uri,
perms=['publish_to_groups'])
def _get_credentials(self, code=''):
logger.info("Tautulli Notifiers :: Requesting access token from {name}.".format(name=self.NAME))
app_id = plexpy.CONFIG.FACEBOOK_APP_ID
app_secret = plexpy.CONFIG.FACEBOOK_APP_SECRET
redirect_uri = plexpy.CONFIG.FACEBOOK_REDIRECT_URI
try:
# Request user access token
api = facebook.GraphAPI(version='2.12')
response = api.get_access_token_from_code(code=code,
redirect_uri=redirect_uri,
app_id=app_id,
app_secret=app_secret)
access_token = response['access_token']
# Request extended user access token
api = facebook.GraphAPI(access_token=access_token, version='2.12')
response = api.extend_access_token(app_id=app_id,
app_secret=app_secret)
plexpy.CONFIG.FACEBOOK_TOKEN = response['access_token']
except Exception as e:
logger.error("Tautulli Notifiers :: Error requesting {name} access token: {e}".format(name=self.NAME, e=e))
plexpy.CONFIG.FACEBOOK_TOKEN = ''
# Clear out temporary config values
plexpy.CONFIG.FACEBOOK_APP_ID = ''
plexpy.CONFIG.FACEBOOK_APP_SECRET = ''
plexpy.CONFIG.FACEBOOK_REDIRECT_URI = ''
return plexpy.CONFIG.FACEBOOK_TOKEN
def _post_facebook(self, **data):
if self.config['group_id']:
api = facebook.GraphAPI(access_token=self.config['access_token'], version='2.12')
try:
api.put_object(parent_object=self.config['group_id'], connection_name='feed', **data)
logger.info("Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
return True
except Exception as e:
logger.error("Tautulli Notifiers :: Error sending {name} post: {e}".format(name=self.NAME, e=e))
return False
else:
logger.error("Tautulli Notifiers :: Error sending {name} post: No {name} Group ID provided.".format(name=self.NAME))
return False
def agent_notify(self, subject='', body='', action='', **kwargs):
if self.config['incl_subject']:
text = subject + '\r\n' + body
else:
text = body
data = {'message': text}
if self.config['incl_card'] and kwargs.get('parameters', {}).get('media_type'):
# Grab formatted metadata
pretty_metadata = PrettyMetadata(kwargs['parameters'])
if pretty_metadata.media_type == 'movie':
provider = self.config['movie_provider']
elif pretty_metadata.media_type in ('show', 'season', 'episode'):
provider = self.config['tv_provider']
elif pretty_metadata.media_type in ('artist', 'album', 'track'):
provider = self.config['music_provider']
else:
provider = None
data['link'] = pretty_metadata.get_provider_link(provider)
return self._post_facebook(**data)
def _return_config_options(self):
config_option = [{'label': 'OAuth Redirect URI',
'value': self.config['redirect_uri'],
'name': 'facebook_redirect_uri',
'description': 'Fill in this address for the "Valid OAuth redirect URIs" '
'in your Facebook App.',
'input_type': 'text'
},
{'label': 'Facebook App ID',
'value': self.config['app_id'],
'name': 'facebook_app_id',
'description': 'Your Facebook app ID.',
'input_type': 'token'
},
{'label': 'Facebook App Secret',
'value': self.config['app_secret'],
'name': 'facebook_app_secret',
'description': 'Your Facebook app secret.',
'input_type': 'token'
},
{'label': 'Request Authorization',
'value': 'Request Authorization',
'name': 'facebook_facebook_auth',
'description': 'Request Facebook authorization. (Ensure you allow the browser pop-up).',
'input_type': 'button'
},
{'label': 'Facebook Access Token',
'value': self.config['access_token'],
'name': 'facebook_access_token',
'description': 'Your Facebook access token. '
'Automatically filled in after requesting authorization.',
'input_type': 'token'
},
{'label': 'Facebook Group ID',
'value': self.config['group_id'],
'name': 'facebook_group_id',
'description': 'Your Facebook Group ID.',
'input_type': 'text'
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'facebook_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Rich Metadata Info',
'value': self.config['incl_card'],
'name': 'facebook_incl_card',
'description': 'Include an info card with a poster and metadata with the notifications.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" '
'data-target="notify_upload_posters">Image Hosting</a> '
'must be enabled under the 3rd Party APIs settings tab.',
'input_type': 'checkbox'
},
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'facebook_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers()
},
{'label': 'TV Show Link Source',
'value': self.config['tv_provider'],
'name': 'facebook_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers()
},
{'label': 'Music Link Source',
'value': self.config['music_provider'],
'name': 'facebook_music_provider',
'description': 'Select the source for music links on the info cards. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers()
}
]
return config_option
class GOTIFY(Notifier):
"""
Gotify notifications
"""
NAME = 'Gotify'
_DEFAULT_CONFIG = {'host': '',
'app_token': '',
'priority': 0,
'incl_subject': 1,
'incl_poster': 0,
'incl_url': 1,
'movie_provider': '',
'tv_provider': '',
'music_provider': ''
}
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {
'extras': {
'client::display': {
'contentType': 'text/markdown'
}
},
'message': body,
'priority': self.config['priority']
}
if self.config['incl_subject']:
data['title'] = subject
headers = {'X-Gotify-Key': self.config['app_token']}
if kwargs.get('parameters', {}).get('media_type'):
# Grab formatted metadata
pretty_metadata = PrettyMetadata(kwargs['parameters'])
if self.config['incl_url']:
if pretty_metadata.media_type == 'movie':
provider = self.config['movie_provider']
elif pretty_metadata.media_type in ('show', 'season', 'episode'):
provider = self.config['tv_provider']
elif pretty_metadata.media_type in ('artist', 'album', 'track'):
provider = self.config['music_provider']
else:
provider = None
provider_link = pretty_metadata.get_provider_link(provider)
data['extras']['client::notification'] = {'click': {'url': provider_link}}
if self.config['incl_poster']:
poster_url = pretty_metadata.get_poster_url()
data['message'] += '\n\n![]({})'.format(poster_url)
return self.make_request('{}/message'.format(self.config['host']), headers=headers, json=data)
def _return_config_options(self):
config_option = [{'label': 'Gotify Host Address',
'value': self.config['host'],
'name': 'gotify_host',
'description': 'Host running Gotify (e.g. http://localhost:8080).',
'input_type': 'text'
},
{'label': 'Gotify App Token',
'value': self.config['app_token'],
'name': 'gotify_app_token',
'description': 'Your Gotify app token.',
'input_type': 'token'
},
{'label': 'Priority',
'value': self.config['priority'],
'name': 'gotify_priority',
'description': 'Set the notification priority.',
'input_type': 'select',
'select_options': {0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7}
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'gotify_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Poster Image',
'value': self.config['incl_poster'],
'name': 'gotify_incl_poster',
'description': 'Include a poster with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Open URL on Notification Click (Android Only)',
'value': self.config['incl_url'],
'name': 'gotify_incl_url',
'description': 'Open a URL instead of the Gotify app when clicking on the notifications.',
'input_type': 'checkbox'
},
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'gotify_movie_provider',
'description': 'Select the source for movie links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers()
},
{'label': 'TV Show Link Source',
'value': self.config['tv_provider'],
'name': 'gotify_tv_provider',
'description': 'Select the source for tv show links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers()
},
{'label': 'Music Link Source',
'value': self.config['music_provider'],
'name': 'gotify_music_provider',
'description': 'Select the source for music links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers()
}
]
return config_option
class GROUPME(Notifier):
"""
GroupMe notifications
"""
NAME = 'GroupMe'
_DEFAULT_CONFIG = {'access_token': '',
'bot_id': '',
'incl_subject': 1,
'incl_poster': 0
}
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'bot_id': self.config['bot_id']}
if self.config['incl_subject']:
data['text'] = subject + '\r\n' + body
else:
data['text'] = body
if self.config['incl_poster'] and kwargs.get('parameters'):
pretty_metadata = PrettyMetadata(kwargs.get('parameters'))
image = pretty_metadata.get_image()
if image:
headers = {'X-Access-Token': self.config['access_token'],
'Content-Type': 'image/png'}
r = requests.post('https://image.groupme.com/pictures', headers=headers, data=image[1])
if r.status_code == 200:
logger.info("Tautulli Notifiers :: {name} poster sent.".format(name=self.NAME))
r_content = r.json()
data['attachments'] = [{'type': 'image',
'url': r_content['payload']['picture_url']}]
else:
logger.error("Tautulli Notifiers :: {name} poster failed: "
"[{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
logger.debug("Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
return self.make_request('https://api.groupme.com/v3/bots/post', json=data)
def _return_config_options(self):
config_option = [{'label': 'GroupMe Access Token',
'value': self.config['access_token'],
'name': 'groupme_access_token',
'description': 'Your GroupMe access token.',
'input_type': 'token'
},
{'label': 'GroupMe Bot ID',
'value': self.config['bot_id'],
'name': 'groupme_bot_id',
'description': 'Your GroupMe bot ID.',
'input_type': 'token'
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'groupme_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Poster Image',
'value': self.config['incl_poster'],
'name': 'groupme_incl_poster',
'description': 'Include a poster with the notifications.',
'input_type': 'checkbox'
}
]
return config_option
class GROWL(Notifier):
"""
Growl notifications, for OS X.
"""
NAME = 'Growl'
_DEFAULT_CONFIG = {'host': '',
'password': ''
}
def agent_notify(self, subject='', body='', action='', **kwargs):
# Split host and port
if self.config['host'] == "":
host, port = "localhost", 23053
if ":" in self.config['host']:
host, port = self.config['host'].split(':', 1)
port = int(port)
else:
host, port = self.config['host'], 23053
# If password is empty, assume none
if self.config['password'] == "":
password = None
else:
password = self.config['password']
# Register notification
growl = gntp.notifier.GrowlNotifier(
applicationName='Tautulli',
notifications=['New Event'],
defaultNotifications=['New Event'],
hostname=host,
port=port,
password=password
)
try:
growl.register()
except gntp.notifier.errors.NetworkError:
logger.error("Tautulli Notifiers :: {name} notification failed: network error".format(name=self.NAME))
return False
except gntp.notifier.errors.AuthError:
logger.error("Tautulli Notifiers :: {name} notification failed: authentication error".format(name=self.NAME))
return False
# Send it, including an image
image_file = os.path.join(str(plexpy.PROG_DIR),
"data/interfaces/default/images/logo-circle.png")
with open(image_file, 'rb') as f:
image = f.read()
try:
growl.notify(
noteType='New Event',
title=subject,
description=body,
icon=image
)
logger.info("Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
return True
except gntp.notifier.errors.NetworkError:
logger.error("Tautulli Notifiers :: {name} notification failed: network error".format(name=self.NAME))
return False
def _return_config_options(self):
config_option = [{'label': 'Growl Host',
'value': self.config['host'],
'name': 'growl_host',
'description': 'Your Growl hostname or IP address.',
'input_type': 'text'
},
{'label': 'Growl Password',
'value': self.config['password'],
'name': 'growl_password',
'description': 'Your Growl password.',
'input_type': 'password'
}
]
return config_option
class IFTTT(Notifier):
"""
IFTTT notifications
"""
NAME = 'IFTTT'
_DEFAULT_CONFIG = {'key': '',
'event': 'tautulli',
'value3': '',
}
def agent_notify(self, subject='', body='', action='', **kwargs):
event = str(self.config['event']).format(action=action)
data = {'value1': subject,
'value2': body}
if self.config['value3']:
pretty_metadata = PrettyMetadata(kwargs['parameters'])
data['value3'] = pretty_metadata.parameters.get(self.config['value3'], '')
headers = {'Content-type': 'application/json'}
return self.make_request('https://maker.ifttt.com/trigger/{}/with/key/{}'.format(event, self.config['key']),
headers=headers, json=data)
def _return_config_options(self):
config_option = [{'label': 'IFTTT Webhook Key',
'value': self.config['key'],
'name': 'ifttt_key',
'description': 'Your IFTTT webhook key. You can get a key from'
' <a href="' + helpers.anon_url('https://ifttt.com/maker_webhooks') +
'" target="_blank" rel="noreferrer">here</a>.',
'input_type': 'token'
},
{'label': 'IFTTT Event',
'value': self.config['event'],
'name': 'ifttt_event',
'description': 'The IFTTT maker event to fire. You can include'
' <span class="inline-pre">{action}</span>'
' to be substituted with the action name.'
' The notification subject and body will be sent'
' as <span class="inline-pre">value1</span>'
' and <span class="inline-pre">value2</span> respectively.',
'input_type': 'text'
},
{'label': 'Value 3',
'value': self.config['value3'],
'name': 'ifttt_value3',
'description': 'Optional: Select a parameter to send as <span class="inline-pre">value3</span>.',
'input_type': 'select',
'select_options': PrettyMetadata().get_parameters()
}
]
return config_option
class JOIN(Notifier):
"""
Join notifications
"""
NAME = 'Join'
_DEFAULT_CONFIG = {'api_key': '',
'device_names': [],
'priority': 2,
'incl_subject': 1,
'incl_poster': 0,
'movie_provider': '',
'tv_provider': '',
'music_provider': ''
}
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'apikey': self.config['api_key'],
'deviceNames': ','.join(self.config['device_names']),
'text': body}
if self.config['incl_subject']:
data['title'] = subject
if kwargs.get('parameters', {}).get('media_type'):
# Grab formatted metadata
pretty_metadata = PrettyMetadata(kwargs['parameters'])
poster_url = pretty_metadata.get_poster_url()
if poster_url and self.config['incl_poster']:
data['icon'] = poster_url
if pretty_metadata.media_type == 'movie':
provider = self.config['movie_provider']
elif pretty_metadata.media_type in ('show', 'season', 'episode'):
provider = self.config['tv_provider']
elif pretty_metadata.media_type in ('artist', 'album', 'track'):
provider = self.config['music_provider']
else:
provider = None
provider_link = pretty_metadata.get_provider_link(provider)
if provider_link:
data['url'] = provider_link
r = requests.post('https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush', params=data)
if r.status_code == 200:
response_data = r.json()
if response_data.get('success'):
logger.info("Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
return True
else:
error_msg = response_data.get('errorMessage')
logger.error("Tautulli Notifiers :: {name} notification failed: {msg}".format(name=self.NAME, msg=error_msg))
return False
else:
logger.error("Tautulli Notifiers :: {name} notification failed: [{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
logger.debug("Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
return False
def get_devices(self):
devices = {d: d for d in self.config['device_names']}
devices.update({'': ''})
if self.config['api_key']:
params = {'apikey': self.config['api_key']}
try:
r = requests.get('https://joinjoaomgcd.appspot.com/_ah/api/registration/v1/listDevices', params=params)
if r.status_code == 200:
response_data = r.json()
if response_data.get('success'):
response_devices = response_data.get('records', [])
devices.update({d['deviceName']: d['deviceName'] for d in response_devices})
else:
error_msg = response_data.get('errorMessage')
logger.error("Tautulli Notifiers :: Unable to retrieve {name} devices list: {msg}".format(name=self.NAME, msg=error_msg))
else:
logger.error("Tautulli Notifiers :: Unable to retrieve {name} devices list: [{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
logger.debug("Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
except Exception as e:
logger.error("Tautulli Notifiers :: Unable to retrieve {name} devices list: {msg}".format(name=self.NAME, msg=e))
return devices
def _return_config_options(self):
config_option = [{'label': 'Join API Key',
'value': self.config['api_key'],
'name': 'join_api_key',
'description': 'Your Join API key. Required for group notifications.',
'input_type': 'token',
'refresh': True
},
{'label': 'Device Name(s)',
'value': self.config['device_names'],
'name': 'join_device_names',
'description': 'Select your Join device(s).',
'input_type': 'select',
'select_options': self.get_devices()
},
{'label': 'Priority',
'value': self.config['priority'],
'name': 'join_priority',
'description': 'Set the notification priority.',
'input_type': 'select',
'select_options': {-2: -2, -1: -1, 0: 0, 1: 1, 2: 2}
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'join_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Poster Image',
'value': self.config['incl_poster'],
'name': 'join_incl_poster',
'description': 'Include a poster with the notifications.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" '
'data-target="notify_upload_posters">Image Hosting</a> '
'must be enabled under the 3rd Party APIs settings tab.',
'input_type': 'checkbox'
},
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'join_movie_provider',
'description': 'Select the source for movie links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers()
},
{'label': 'TV Show Link Source',
'value': self.config['tv_provider'],
'name': 'join_tv_provider',
'description': 'Select the source for tv show links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers()
},
{'label': 'Music Link Source',
'value': self.config['music_provider'],
'name': 'join_music_provider',
'description': 'Select the source for music links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers()
}
]
return config_option
class LUNASEA(Notifier):
"""
LunaSea Notifications
"""
NAME = 'LunaSea'
_DEFAULT_CONFIG = {'hook': '',
'profile': '',
'incl_subject': 1
}
def agent_notify(self, subject='', body='', action='', **kwargs):
if self.config['incl_subject']:
text = subject + '\r\n' + body
else:
text = body
if self.config['profile']:
auth = HTTPBasicAuth(self.config['profile'], '')
else:
auth = None
pretty_metadata = PrettyMetadata(kwargs['parameters'])
payload = {
'action': action,
'data': {
'message': text,
'user': pretty_metadata.parameters.get('user'),
'user_id': pretty_metadata.parameters.get('user_id'),
'player': pretty_metadata.parameters.get('player'),
'title': pretty_metadata.get_title(),
'poster_url': pretty_metadata.get_poster_url(),
'session_key': pretty_metadata.parameters.get('session_key'),
'session_id': pretty_metadata.parameters.get('session_id'),
'user_streams': pretty_metadata.parameters.get('user_streams'),
'remote_access_reason': pretty_metadata.parameters.get('remote_access_reason'),
'update_version': pretty_metadata.parameters.get('update_version'),
'tautulli_update_version': pretty_metadata.parameters.get('tautulli_update_version')
}
}
return self.make_request(self.config['hook'], json=payload, auth=auth)
def _return_config_options(self):
config_option = [{'label': 'LunaSea Webhook URL',
'value': self.config['hook'],
'name': 'lunasea_hook',
'description': 'Your LunaSea device-based or user-based webhook URL.',
'input_type': 'token'
},
{'label': 'LunaSea Profile',
'value': self.config['profile'],
'name': 'lunasea_profile',
'description': 'Your LunaSea profile name. Leave blank for the default profile.',
'input_type': 'text'
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'lunasea_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
}
]
return config_option
class MICROSOFTTEAMS(Notifier):
"""
Microsoft Teams Notifications
"""
NAME = 'Microsoft Teams'
_DEFAULT_CONFIG = {'hook': '',
'incl_subject': 1,
'incl_card': 0,
'incl_description': 1,
'incl_pmslink': 0,
'poster_size': 2,
'movie_provider': '',
'tv_provider': '',
'music_provider': ''
}
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {
'type': 'message'
}
attachment = {
'contentType': 'application/vnd.microsoft.card.adaptive'
}
content = {
'$schema': 'http://adaptivecards.io/schemas/adaptive-card.json',
'type': 'AdaptiveCard',
'version': '1.4',
}
card = []
if self.config['incl_subject']:
card.append({
'type': 'TextBlock',
'size': 'Large',
'weight': 'Bolder',
'text': subject
})
card.append({
'type': 'TextBlock',
'text': body,
'wrap': True
})
if self.config['incl_card'] and kwargs.get('parameters', {}).get('media_type'):
# Grab formatted metadata
pretty_metadata = PrettyMetadata(kwargs['parameters'])
if pretty_metadata.media_type == 'movie':
provider = self.config['movie_provider']
elif pretty_metadata.media_type in ('show', 'season', 'episode'):
provider = self.config['tv_provider']
elif pretty_metadata.media_type in ('artist', 'album', 'track'):
provider = self.config['music_provider']
else:
provider = None
poster_url = pretty_metadata.get_poster_url()
provider_name = pretty_metadata.get_provider_name(provider)
provider_link = pretty_metadata.get_provider_link(provider)
title = pretty_metadata.get_title('\u00B7')
description = pretty_metadata.get_description()
plex_url = pretty_metadata.get_plex_url()
columns = []
if poster_url and self.config['poster_size']:
columns.append({
'type': 'Column',
'width': 'auto',
'items': [
{
'type': 'Image',
'url': poster_url,
'altText': title,
'size': 'Large',
'height': '{}px'.format(self.config['poster_size'] * 75)
}
]
})
columns.append({
'type': 'Column',
'width': 'stretch',
'items': []
})
columns[-1]['items'].append({
'type': 'TextBlock',
'weight': 'Bolder',
'text': title,
'wrap': True
})
if self.config['incl_description']:
columns[-1]['items'].append({
'type': 'TextBlock',
'text': description,
'size': 'Small',
'spacing': 'Small',
'wrap': True
})
card.append({
'type': 'ColumnSet',
'padding': 'Default',
'spacing': 'Large',
'columns': columns
})
actions = []
if provider_link:
actions.append({
'type': 'Action.OpenUrl',
'title': 'View on {}'.format(provider_name),
'url': provider_link
})
if self.config['incl_pmslink']:
actions.append({
'type': 'Action.OpenUrl',
'title': 'View on Plex',
'url': plex_url
})
if actions:
card.append({
'type': 'ActionSet',
'actions': actions
})
content['body'] = card
attachment['content'] = content
data['attachments'] = [attachment]
headers = {'Content-type': 'application/json'}
return self.make_request(self.config['hook'], headers=headers, json=data)
def _return_config_options(self):
config_option = [{'label': 'Teams Webhook URL',
'value': self.config['hook'],
'name': 'microsoftteams_hook',
'description': 'Your Microsoft Teams incoming webhook URL.',
'input_type': 'token'
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'microsoftteams_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Rich Metadata Info',
'value': self.config['incl_card'],
'name': 'microsoftteams_incl_card',
'description': 'Include an info card with a poster and metadata with the notifications.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" '
'data-target="notify_upload_posters">Image Hosting</a> '
'must be enabled under the 3rd Party APIs settings tab.',
'input_type': 'checkbox'
},
{'label': 'Include Summary',
'value': self.config['incl_description'],
'name': 'microsoftteams_incl_description',
'description': 'Include a summary for the media on the info card.',
'input_type': 'checkbox'
},
{'label': 'Include Link to Plex Web',
'value': self.config['incl_pmslink'],
'name': 'microsoftteams_incl_pmslink',
'description': 'Include a second link to the media in Plex Web on the info card.',
'input_type': 'checkbox'
},
{'label': 'Poster Size',
'value': self.config['poster_size'],
'name': 'microsoftteams_poster_size',
'description': 'Select the size of the poster on the info card.',
'input_type': 'select',
'select_options': {
0: 'None',
1: 'Small',
2: 'Medium',
3: 'Large'}
},
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'microsoftteams_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers()
},
{'label': 'TV Show Link Source',
'value': self.config['tv_provider'],
'name': 'microsoftteams_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers()
},
{'label': 'Music Link Source',
'value': self.config['music_provider'],
'name': 'microsoftteams_music_provider',
'description': 'Select the source for music links on the info cards. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers()
}
]
return config_option
class MQTT(Notifier):
"""
MQTT notifications
"""
NAME = 'MQTT'
_DEFAULT_CONFIG = {'broker': '',
'port': 1883,
'protocol': 'MQTTv311',
'username': '',
'password': '',
'clientid': 'tautulli',
'topic': '',
'qos': 1,
'retain': 0,
'keep_alive': 60,
'as_json': 0
}
def agent_notify(self, subject='', body='', action='', **kwargs):
topic = self.config['topic']
if not topic:
logger.error("Tautulli Notifiers :: MQTT topic not specified.")
return
topic = topic.format(**kwargs.get('parameters', {}))
if self.config['as_json']:
subject = json.loads(subject)
body = json.loads(body)
data = {'subject': subject,
'body': body,
'topic': topic}
auth = {}
if self.config['username']:
auth['username'] = self.config['username']
if self.config['password']:
auth['password'] = self.config['password']
protocol = getattr(paho.mqtt.client, self.config['protocol'])
logger.info("Tautulli Notifiers :: Sending {name} notification...".format(name=self.NAME))
paho.mqtt.publish.single(
topic, payload=json.dumps(data), qos=self.config['qos'], retain=bool(self.config['retain']),
hostname=self.config['broker'], port=self.config['port'], client_id=self.config['clientid'],
keepalive=self.config['keep_alive'], auth=auth or None, protocol=protocol
)
logger.info("Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
return True
def _return_config_options(self):
config_option = [{'label': 'Broker',
'value': self.config['broker'],
'name': 'mqtt_broker',
'description': 'The hostname or IP address of the MQTT broker.',
'input_type': 'text'
},
{'label': 'Port',
'value': self.config['port'],
'name': 'mqtt_port',
'description': 'The network port for connecting to the MQTT broker.',
'input_type': 'number'
},
{'label': 'Protocol',
'value': self.config['protocol'],
'name': 'mqtt_protocol',
'description': 'The MQTT protocol version.',
'input_type': 'select',
'select_options': {'MQTTv31': '3.1',
'MQTTv311': '3.1.1',
'MQTTv5': '5.0'
}
},
{'label': 'Client ID',
'value': self.config['clientid'],
'name': 'mqtt_clientid',
'description': 'The client ID for connecting to the MQTT broker.',
'input_type': 'text'
},
{'label': 'Username',
'value': self.config['username'],
'name': 'mqtt_username',
'description': 'The username to authenticate with the MQTT broker.',
'input_type': 'text'
},
{'label': 'Password',
'value': self.config['password'],
'name': 'mqtt_password',
'description': 'The password to authenticate with the MQTT broker.',
'input_type': 'password'
},
{'label': 'Topic',
'value': self.config['topic'],
'name': 'mqtt_topic',
'description': 'The topic to publish notifications to. You can include'
' <a href="#notify-text-sub-modal" data-toggle="modal">notification parameters</a>'
' to be substituted.',
'input_type': 'text'
},
{'label': 'Quality of Service',
'value': self.config['qos'],
'name': 'mqtt_qos',
'description': 'The quality of service level to use when publishing the notification.',
'input_type': 'select',
'select_options': {0: 0,
1: 1,
2: 2
}
},
{'label': 'Retain Message',
'value': self.config['retain'],
'name': 'mqtt_retain',
'description': 'Set the message to be retained on the MQTT broker.',
'input_type': 'checkbox'
},
{'label': 'Keep-Alive',
'value': self.config['keep_alive'],
'name': 'mqtt_keep_alive',
'description': 'Maximum period in seconds before timing out the connection with the broker.',
'input_type': 'number'
},
{'label': 'Send Message as JSON',
'value': self.config['as_json'],
'name': 'mqtt_as_json',
'description': 'Parse and send the subject and body as JSON instead of as a raw string.',
'input_type': 'checkbox'
},
]
return config_option
class NTFY(Notifier):
"""
ntfy notifications
"""
NAME = 'ntfy'
_DEFAULT_CONFIG = {'host': '',
'access_token': '',
'topic': '',
'priority': 'default',
'incl_subject': 1,
'incl_description': 1,
'incl_poster': 0,
'incl_url': 0,
'incl_pmslink': 0,
'movie_provider': '',
'tv_provider': '',
'music_provider': ''
}
def agent_notify(self, subject='', body='', action='', **kwargs):
method = "POST"
url = f"{self.config['host']}/{self.config['topic']}"
params = {}
data = body.encode('utf-8')
headers = {
'Priority': self.config['priority'],
'Authorization': f'Bearer {self.config["access_token"]}',
'Icon': 'https://tautulli.com/images/favicon.ico'
}
# Add optional subject
if self.config['incl_subject']:
headers['Title'] = subject.encode('utf-8')
# Add optional parameters (dependent on notification type + metadata extraction)
if kwargs.get('parameters', {}).get('media_type'):
# Grab formatted metadata
pretty_metadata = PrettyMetadata(kwargs['parameters'])
# Add optional description
if self.config['incl_description']:
description = pretty_metadata.get_description()
if description:
data += f"\n\n{description}".encode('utf-8')
# Add optional poster
if self.config['incl_poster']:
method = "PUT" # Need to use PUT instead of POST to send attachments
image_file_name, image_content, image_file_type = pretty_metadata.get_image()
if image_file_name and image_content:
# Image data will take up "data", message content needs to shift to the "message" query parameter
params['message'] = data
data = image_content
headers['Filename'] = image_file_name
else:
# Fallback to default Tautulli media type poster
# Can keep message content in "data" payload and just add a header for the attachment
poster_url = pretty_metadata.get_poster_url()
headers['Attach'] = poster_url
# Add optional links (actions)
actions = []
if self.config['incl_url']:
if pretty_metadata.media_type == 'movie':
provider = self.config['movie_provider']
elif pretty_metadata.media_type in ('show', 'season', 'episode'):
provider = self.config['tv_provider']
elif pretty_metadata.media_type in ('artist', 'album', 'track'):
provider = self.config['music_provider']
else:
provider = None
provider_name = pretty_metadata.get_provider_name(provider)
provider_link = pretty_metadata.get_provider_link(provider)
actions.append(f"view, View on {provider_name}, {provider_link}, clear=true")
if self.config['incl_pmslink']:
plex_url = pretty_metadata.get_plex_url()
actions.append(f"view, View on Plex, {plex_url}, clear=true")
if actions:
headers['Actions'] = ';'.join(actions)
return self.make_request(url=url, method=method, headers=headers, params=params, data=data)
def _return_config_options(self):
config_option = [{'label': 'ntfy Host Address',
'value': self.config['host'],
'name': 'ntfy_host',
'description': 'Host running ntfy (e.g. http://localhost:80).',
'input_type': 'text'
},
{'label': 'ntfy Access Token',
'value': self.config['access_token'],
'name': 'ntfy_access_token',
'description': 'Your ntfy access token.',
'input_type': 'token'
},
{'label': 'ntfy Topic',
'value': self.config['topic'],
'name': 'ntfy_topic',
'description': 'The topic to publish notifications to.',
'input_type': 'text'
},
{'label': 'Priority',
'value': self.config['priority'],
'name': 'ntfy_priority',
'description': 'Set the notification priority.',
'input_type': 'select',
'select_options': {
'min': 1,
'low': 2,
'default': 3,
'high': 4,
'max': 5
}
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'ntfy_incl_subject',
'description': 'Include a subject line in the notification.',
'input_type': 'checkbox'
},
{'label': 'Include Poster Image',
'value': self.config['incl_poster'],
'name': 'ntfy_incl_poster',
'description': 'Include a poster of the media item in the notification.',
'input_type': 'checkbox'
},
{'label': 'Include Summary',
'value': self.config['incl_description'],
'name': 'ntfy_incl_description',
'description': 'Include a summary of the media item in the notification.',
'input_type': 'checkbox'
},
{'label': 'Include Link to Metadata Provider',
'value': self.config['incl_url'],
'name': 'ntfy_incl_url',
'description': 'Include a link to the media item on the metadata provider in the notification.',
'input_type': 'checkbox'
},
{'label': 'Include Link to Plex Web',
'value': self.config['incl_pmslink'],
'name': 'ntfy_incl_pmslink',
'description': 'Include a link to the media item in Plex Web in the notification.',
'input_type': 'checkbox'
},
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'ntfy_movie_provider',
'description': 'Select the source for movie links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers()
},
{'label': 'TV Show Link Source',
'value': self.config['tv_provider'],
'name': 'ntfy_tv_provider',
'description': 'Select the source for TV show links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers()
},
{'label': 'Music Link Source',
'value': self.config['music_provider'],
'name': 'ntfy_music_provider',
'description': 'Select the source for music links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers()
}
]
return config_option
class OSX(Notifier):
"""
macOS notifications
"""
NAME = 'macOS'
_DEFAULT_CONFIG = {'notify_app': '/Applications/Tautulli'
}
def __init__(self, config=None):
super(OSX, self).__init__(config=config)
try:
self.objc = __import__("objc")
self.AppKit = __import__("AppKit")
except:
# logger.error("Tautulli Notifiers :: Cannot load OSX Notifications agent.")
pass
def validate(self):
try:
self.objc = __import__("objc")
self.AppKit = __import__("AppKit")
return True
except:
return False
def _swizzle(self, cls, SEL, func):
old_IMP = cls.instanceMethodForSelector_(SEL)
def wrapper(self, *args, **kwargs):
return func(self, old_IMP, *args, **kwargs)
new_IMP = self.objc.selector(wrapper, selector=old_IMP.selector,
signature=old_IMP.signature)
self.objc.classAddMethod(cls, SEL, new_IMP)
def _swizzled_bundleIdentifier(self, original, swizzled):
return 'ade.tautulli.osxnotify'
def agent_notify(self, subject='', body='', action='', **kwargs):
subtitle = kwargs.get('subtitle', '')
sound = kwargs.get('sound', '')
image = kwargs.get('image', '')
try:
self._swizzle(self.objc.lookUpClass('NSBundle'),
b'bundleIdentifier',
self._swizzled_bundleIdentifier)
NSUserNotification = self.objc.lookUpClass('NSUserNotification')
NSUserNotificationCenter = self.objc.lookUpClass('NSUserNotificationCenter')
NSAutoreleasePool = self.objc.lookUpClass('NSAutoreleasePool')
if not NSUserNotification or not NSUserNotificationCenter:
return False
pool = NSAutoreleasePool.alloc().init()
notification = NSUserNotification.alloc().init()
notification.setTitle_(subject)
if subtitle:
notification.setSubtitle_(subtitle)
if body:
notification.setInformativeText_(body)
if sound:
notification.setSoundName_("NSUserNotificationDefaultSoundName")
if image:
source_img = self.AppKit.NSImage.alloc().initByReferencingFile_(image)
notification.setContentImage_(source_img)
# notification.set_identityImage_(source_img)
notification.setHasActionButton_(False)
notification_center = NSUserNotificationCenter.defaultUserNotificationCenter()
notification_center.deliverNotification_(notification)
logger.info("Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
del pool
return True
except Exception as e:
logger.error("Tautulli Notifiers :: {name} failed: {e}".format(name=self.NAME, e=e))
return False
def _return_config_options(self):
config_option = [{'label': 'Register Notify App',
'value': self.config['notify_app'],
'name': 'osx_notify_app',
'description': 'Enter the path/application name to be registered with the Notification Center. '
'Default is <span class="inline-pre">/Applications/Tautulli</span>.',
'input_type': 'text'
},
{'label': 'Register App',
'value': 'Register App',
'name': 'osx_notify_register',
'description': 'Register Tautulli with the Notification Center.',
'input_type': 'button'
}
]
return config_option
class PLEX(Notifier):
"""
Plex Home Theater notifications
"""
NAME = 'Plex Home Theater'
_DEFAULT_CONFIG = {'hosts': '',
'username': '',
'password': '',
'display_time': 5,
'image': ''
}
def _sendhttp(self, host, command):
url_command = urlencode(command)
url = host + '/xbmcCmds/xbmcHttp/?' + url_command
if self.config['password']:
return request.request_content(url, auth=(self.config['username'], self.config['password']))
else:
return request.request_content(url)
def _sendjson(self, host, method, params=None):
params = params or {}
data = [{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}]
headers = {'Content-Type': 'application/json'}
url = host + '/jsonrpc'
if self.config['password']:
response = request.request_json(url, method="post", data=json.dumps(data), headers=headers,
auth=(self.config['username'], self.config['password']))
else:
response = request.request_json(url, method="post", data=json.dumps(data), headers=headers)
if response:
return response[0]['result']
def agent_notify(self, subject='', body='', action='', **kwargs):
hosts = [x.strip() for x in self.config['hosts'].split(',')]
if self.config['display_time'] > 0:
display_time = 1000 * self.config['display_time'] # in ms
else:
display_time = 5000
if self.config['image']:
image = self.config['image']
else:
image = os.path.join(plexpy.DATA_DIR, os.path.abspath("data/interfaces/default/images/logo-circle.png"))
for host in hosts:
logger.info("Tautulli Notifiers :: Sending notification command to {name} @ {host}".format(name=self.NAME, host=host))
try:
version = self._sendjson(host, 'Application.GetProperties', {'properties': ['version']})['version']['major']
if version < 12: # Eden
notification = subject + "," + body + "," + str(display_time)
notifycommand = {'command': 'ExecBuiltIn', 'parameter': 'Notification(' + notification + ')'}
request = self._sendhttp(host, notifycommand)
else: # Frodo
params = {'title': subject, 'message': body, 'displaytime': display_time, 'image': image}
request = self._sendjson(host, 'GUI.ShowNotification', params)
if not request:
raise Exception
else:
logger.info("Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
except Exception as e:
logger.error("Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e))
return False
return True
def _return_config_options(self):
config_option = [{'label': 'Plex Home Theater Host Address',
'value': self.config['hosts'],
'name': 'plex_hosts',
'description': 'Host running Plex Home Theater (eg. http://localhost:3005). Separate multiple hosts with commas (,).',
'input_type': 'text'
},
{'label': 'Plex Home Theater Username',
'value': self.config['username'],
'name': 'plex_username',
'description': 'Username of your Plex Home Theater client API (blank for none).',
'input_type': 'text'
},
{'label': 'Plex Home Theater Password',
'value': self.config['password'],
'name': 'plex_password',
'description': 'Password of your Plex Home Theater client API (blank for none).',
'input_type': 'password'
},
{'label': 'Notification Duration',
'value': self.config['display_time'],
'name': 'plex_display_time',
'description': 'The duration (in seconds) for the notification to stay on screen.',
'input_type': 'number'
},
{'label': 'Notification Icon',
'value': self.config['image'],
'name': 'plex_image',
'description': 'Full path or URL to an image to display with the notification. Leave blank for the default.',
'input_type': 'text'
}
]
return config_option
class PLEXMOBILEAPP(Notifier):
"""
Plex Mobile App Notifications
"""
NAME = 'Plex Android / iOS App'
NOTIFICATION_URL = 'https://notifications.plex.tv/api/v1/notifications'
_DEFAULT_CONFIG = {'user_ids': [],
'tap_action': 'preplay',
}
def __init__(self, config=None):
super(PLEXMOBILEAPP, self).__init__(config=config)
self.configurations = {
'created': {'group': 'media', 'identifier': 'tv.plex.notification.library.new'},
'play': {'group': 'media', 'identifier': 'tv.plex.notification.playback.started'},
'newdevice': {'group': 'admin', 'identifier': 'tv.plex.notification.device.new'}
}
def agent_notify(self, subject='', body='', action='', **kwargs):
if action not in self.configurations and not action.startswith('test'):
logger.error(u"Tautulli Notifiers :: Notification action %s not allowed for %s." % (action, self.NAME))
return
if action == 'test':
tests = []
for configuration in self.configurations:
tests.append(self.agent_notify(subject=subject, body=body, action='test_'+configuration))
return all(tests)
configuration_action = action.split('test_')[-1]
# No subject to always show up regardless of client selected filters
# icon can be info, warning, or error
# play = true to start playing when tapping the notification
# Send the minimal amount of data necessary through Plex servers
data = {
'group': self.configurations[configuration_action]['group'],
'identifier': self.configurations[configuration_action]['identifier'],
'to': self.config['user_ids'],
'data': {
'provider': {
'identifier': plexpy.CONFIG.PMS_IDENTIFIER,
'title': helpers.pms_name()
}
}
}
pretty_metadata = PrettyMetadata(kwargs.get('parameters'))
if action.startswith('test'):
data['data']['player'] = {
'title': 'Device',
'platform': 'Platform',
'machineIdentifier': 'Tautulli'
}
data['data']['user'] = {
'title': 'User',
'id': 0
}
data['metadata'] = {
'type': 'movie',
'title': subject,
'year': body
}
elif action in ('play', 'newdevice'):
data['data']['player'] = {
'title': pretty_metadata.parameters['player'],
'platform': pretty_metadata.parameters['platform'],
'machineIdentifier': pretty_metadata.parameters['machine_id']
}
data['data']['user'] = {
'title': pretty_metadata.parameters['user'],
'id': pretty_metadata.parameters['user_id'],
'thumb': pretty_metadata.parameters['user_thumb'],
}
elif action == 'created':
# No addition data required for recently added
pass
else:
logger.error(u"Tautulli Notifiers :: Notification action %s not supported for %s." % (action, self.NAME))
return
if data['group'] == 'media' and not action.startswith('test'):
media_type = pretty_metadata.media_type
uri_rating_key = None
if media_type == 'movie':
metadata = {
'type': media_type,
'title': pretty_metadata.parameters['title'],
'year': pretty_metadata.parameters['year'],
'thumb': pretty_metadata.parameters['thumb']
}
elif media_type == 'show':
metadata = {
'type': media_type,
'title': pretty_metadata.parameters['show_name'],
'thumb': pretty_metadata.parameters['thumb']
}
elif media_type == 'season':
metadata = {
'type': 'show',
'title': pretty_metadata.parameters['show_name'],
'thumb': pretty_metadata.parameters['thumb'],
}
data['data']['count'] = pretty_metadata.parameters['episode_count']
elif media_type == 'episode':
metadata = {
'type': media_type,
'title': pretty_metadata.parameters['episode_name'],
'grandparentTitle': pretty_metadata.parameters['show_name'],
'index': pretty_metadata.parameters['episode_num'],
'parentIndex': pretty_metadata.parameters['season_num'],
'grandparentThumb': pretty_metadata.parameters['grandparent_thumb']
}
elif media_type == 'artist':
metadata = {
'type': media_type,
'title': pretty_metadata.parameters['artist_name'],
'thumb': pretty_metadata.parameters['thumb']
}
elif media_type == 'album':
metadata = {
'type': media_type,
'title': pretty_metadata.parameters['album_name'],
'year': pretty_metadata.parameters['year'],
'parentTitle': pretty_metadata.parameters['artist_name'],
'thumb': pretty_metadata.parameters['thumb'],
}
elif media_type == 'track':
metadata = {
'type': 'album',
'title': pretty_metadata.parameters['album_name'],
'year': pretty_metadata.parameters['year'],
'parentTitle': pretty_metadata.parameters['artist_name'],
'thumb': pretty_metadata.parameters['parent_thumb']
}
uri_rating_key = pretty_metadata.parameters['parent_rating_key']
else:
logger.error(u"Tautulli Notifiers :: Media type %s not supported for %s." % (media_type, self.NAME))
return
data['metadata'] = metadata
data['uri'] = 'server://{}/com.plexapp.plugins.library/library/metadata/{}'.format(
plexpy.CONFIG.PMS_IDENTIFIER, uri_rating_key or pretty_metadata.parameters['rating_key']
)
data['play'] = self.config['tap_action'] == 'play'
headers = {'X-Plex-Token': plexpy.CONFIG.PMS_TOKEN}
return self.make_request(self.NOTIFICATION_URL, headers=headers, json=data)
def get_users(self):
user_ids = {u['user_id']: u['friendly_name'] for u in users.Users().get_users() if u['user_id']}
return user_ids
def _return_config_options(self):
config_option = [{'label': 'Plex User(s)',
'value': self.config['user_ids'],
'name': 'plexmobileapp_user_ids',
'description': 'Select which Plex User(s) to receive notifications.<br>'
'Note: The user(s) must have notifications enabled '
'for the matching Tautulli triggers in their Plex mobile app.',
'input_type': 'select',
'select_options': self.get_users()
},
{'label': 'Notification Tap Action',
'value': self.config['tap_action'],
'name': 'plexmobileapp_tap_action',
'description': 'Set the action when tapping on the notification.',
'input_type': 'select',
'select_options': {'preplay': 'Go to media pre-play screen',
'play': 'Start playing the media'}
},
]
return config_option
class PROWL(Notifier):
"""
Prowl notifications.
"""
NAME = 'Prowl'
_DEFAULT_CONFIG = {'key': '',
'priority': 0
}
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'apikey': self.config['key'],
'application': 'Tautulli',
'event': subject,
'description': body,
'priority': self.config['priority']}
headers = {'Content-type': 'application/x-www-form-urlencoded'}
return self.make_request('https://api.prowlapp.com/publicapi/add', headers=headers, data=data)
def _return_config_options(self):
config_option = [{'label': 'Prowl API Key',
'value': self.config['key'],
'name': 'prowl_key',
'description': 'Your Prowl API key.',
'input_type': 'token'
},
{'label': 'Priority',
'value': self.config['priority'],
'name': 'prowl_priority',
'description': 'Set the notification priority.',
'input_type': 'select',
'select_options': {-2: -2, -1: -1, 0: 0, 1: 1, 2: 2}
}
]
return config_option
class PUSHBULLET(Notifier):
"""
Pushbullet notifications
"""
NAME = 'Pushbullet'
_DEFAULT_CONFIG = {'api_key': '',
'device_id': '',
'channel_tag': '',
'incl_subject': 1,
'incl_poster': 0
}
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'type': 'note',
'body': body}
headers = {'Content-type': 'application/json',
'Access-Token': self.config['api_key']
}
if self.config['incl_subject']:
data['title'] = subject
# Can only send to a device or channel, not both.
if self.config['device_id']:
data['device_iden'] = self.config['device_id']
elif self.config['channel_tag']:
data['channel_tag'] = self.config['channel_tag']
if self.config['incl_poster'] and kwargs.get('parameters', {}).get('media_type'):
# Grab formatted metadata
pretty_metadata = PrettyMetadata(kwargs['parameters'])
image = pretty_metadata.get_image()
if image:
file_json = {'file_name': image[0], 'file_type': image[2]}
files = {'file': image}
r = requests.post('https://api.pushbullet.com/v2/upload-request', headers=headers, json=file_json)
file_response = r.json()
upload_url = file_response.pop('upload_url')
r = requests.post(upload_url, files=files)
if r.status_code == 204:
data['type'] = 'file'
file_response.pop('data', None)
data.update(file_response)
else:
logger.error("Tautulli Notifiers :: Unable to upload image to {name}: "
"[{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
logger.debug("Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
return self.make_request('https://api.pushbullet.com/v2/pushes', headers=headers, json=data)
def get_devices(self):
devices = {'': ''}
if self.config['api_key']:
headers = {'Content-type': "application/json",
'Access-Token': self.config['api_key']
}
try:
r = requests.get('https://api.pushbullet.com/v2/devices', headers=headers)
if r.status_code == 200:
response_data = r.json()
pushbullet_devices = response_data.get('devices', [])
devices.update({d['iden']: d['nickname'] for d in pushbullet_devices if d['active']})
else:
logger.error("Tautulli Notifiers :: Unable to retrieve {name} devices list: "
"[{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
logger.debug("Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
except Exception as e:
logger.error("Tautulli Notifiers :: Unable to retrieve {name} devices list: {msg}".format(name=self.NAME, msg=e))
return devices
def _return_config_options(self):
config_option = [{'label': 'Pushbullet Access Token',
'value': self.config['api_key'],
'name': 'pushbullet_api_key',
'description': 'Your Pushbullet access token.',
'input_type': 'token',
'refresh': True
},
{'label': 'Device',
'value': self.config['device_id'],
'name': 'pushbullet_device_id',
'description': 'Set your Pushbullet device. If set, will override channel tag. '
'Leave blank to notify on all devices.',
'input_type': 'select',
'select_options': self.get_devices()
},
{'label': 'Channel',
'value': self.config['channel_tag'],
'name': 'pushbullet_channel_tag',
'description': 'A channel tag (optional).',
'input_type': 'text'
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'pushbullet_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Poster Image',
'value': self.config['incl_poster'],
'name': 'pushbullet_incl_poster',
'description': 'Include a poster with the notifications.',
'input_type': 'checkbox'
}
]
return config_option
class PUSHOVER(Notifier):
"""
Pushover notifications
"""
NAME = 'Pushover'
_DEFAULT_CONFIG = {'api_token': '',
'key': '',
'html_support': 1,
'sound': '',
'priority': 0,
'retry': 30,
'expire': 3600,
'incl_url': 1,
'incl_subject': 1,
'incl_poster': 0,
'movie_provider': '',
'tv_provider': '',
'music_provider': ''
}
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'token': self.config['api_token'],
'user': self.config['key'],
'message': body,
'sound': self.config['sound'],
'html': self.config['html_support'],
'priority': self.config['priority'],
'timestamp': helpers.timestamp()}
if self.config['incl_subject']:
data['title'] = subject
if self.config['priority'] == 2:
data['retry'] = max(30, self.config['retry'])
data['expire'] = max(30, self.config['expire'])
headers = {'Content-type': 'application/x-www-form-urlencoded'}
files = {}
if self.config['incl_url'] and kwargs.get('parameters', {}).get('media_type'):
# Grab formatted metadata
pretty_metadata = PrettyMetadata(kwargs['parameters'])
if pretty_metadata.media_type == 'movie':
provider = self.config['movie_provider']
elif pretty_metadata.media_type in ('show', 'season', 'episode'):
provider = self.config['tv_provider']
elif pretty_metadata.media_type in ('artist', 'album', 'track'):
provider = self.config['music_provider']
else:
provider = None
provider_link = pretty_metadata.get_provider_link(provider)
caption = pretty_metadata.get_caption(provider)
data['url'] = provider_link
data['url_title'] = caption
if self.config['incl_poster'] and kwargs.get('parameters', {}).get('media_type'):
# Grab formatted metadata
pretty_metadata = PrettyMetadata(kwargs['parameters'])
image = pretty_metadata.get_image()
if image:
files = {'attachment': image}
headers = {}
return self.make_request('https://api.pushover.net/1/messages.json', headers=headers, data=data, files=files)
def get_sounds(self):
sounds = [
{'value': '', 'text': ''},
{'value': 'alien', 'text': 'Alien Alarm (long)'},
{'value': 'bike', 'text': 'Bike'},
{'value': 'bugle', 'text': 'Bugle'},
{'value': 'cashregister', 'text': 'Cash Register'},
{'value': 'classical', 'text': 'Classical'},
{'value': 'climb', 'text': 'Climb (long)'},
{'value': 'cosmic', 'text': 'Cosmic'},
{'value': 'echo', 'text': 'Pushover Echo (long)'},
{'value': 'falling', 'text': 'Falling'},
{'value': 'gamelan', 'text': 'Gamelan'},
{'value': 'incoming', 'text': 'Incoming'},
{'value': 'intermission', 'text': 'Intermission'},
{'value': 'magic', 'text': 'Magic'},
{'value': 'mechanical', 'text': 'Mechanical'},
{'value': 'none', 'text': 'None (silent)'},
{'value': 'persistent', 'text': 'Persistent (long)'},
{'value': 'pianobar', 'text': 'Piano Bar'},
{'value': 'pushover', 'text': 'Pushover (default)'},
{'value': 'siren', 'text': 'Siren'},
{'value': 'spacealarm', 'text': 'Space Alarm'},
{'value': 'tugboat', 'text': 'Tug Boat'},
{'value': 'updown', 'text': 'Up Down (long)'},
{'value': 'vibrate', 'text': 'Vibrate Only'},
]
if self.config['sound'] not in [s['value'] for s in sounds]:
sounds.append({'value': self.config['sound'], 'text': self.config['sound']})
return sounds
# if self.config['api_token']:
# params = {'token': self.config['api_token']}
#
# r = requests.get('https://api.pushover.net/1/sounds.json', params=params)
#
# if r.status_code == 200:
# response_data = r.json()
# sounds = response_data.get('sounds', {})
# sounds.update({'': ''})
# print sounds
# return sounds
# else:
# logger.error("Tautulli Notifiers :: Unable to retrieve {name} sounds list: "
# "[{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
# logger.debug("Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
# return {'': ''}
#
# else:
# return {'': ''}
def _return_config_options(self):
config_option = [{'label': 'Pushover API Token',
'value': self.config['api_token'],
'name': 'pushover_api_token',
'description': 'Your Pushover API token.',
'input_type': 'token'
},
{'label': 'Pushover User or Group Key',
'value': self.config['key'],
'name': 'pushover_key',
'description': 'Your Pushover user or group key.',
'input_type': 'token'
},
{'label': 'Sound',
'value': self.config['sound'],
'name': 'pushover_sound',
'description': 'Select a notification sound or enter a custom sound name. Leave blank for the default sound.',
'input_type': 'selectize',
'select_options': self.get_sounds(),
'select_all': False
},
{'label': 'Priority',
'value': self.config['priority'],
'name': 'pushover_priority',
'description': 'Set the notification priority.',
'input_type': 'select',
'select_options': {-2: -2, -1: -1, 0: 0, 1: 1, 2: 2}
},
{'label': 'Retry Interval',
'value': self.config['retry'],
'name': 'pushover_retry',
'description': 'Set the interval in seconds to keep retrying the notification.<br>'
'Note: For priority 2 only. Minimum 30 seconds.',
'input_type': 'number'
},
{'label': 'Expire Duration',
'value': self.config['expire'],
'name': 'pushover_expire',
'description': 'Set the duration in seconds when the notification will stop retrying.<br>'
'Note: For priority 2 only. Minimum 30 seconds.',
'input_type': 'number'
},
{'label': 'Enable HTML Support',
'value': self.config['html_support'],
'name': 'pushover_html_support',
'description': 'Style your messages using these HTML tags: b, i, u, a[href], font[color]',
'input_type': 'checkbox'
},
{'label': 'Include supplementary URL',
'value': self.config['incl_url'],
'name': 'pushover_incl_url',
'description': 'Include a supplementary URL with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'pushover_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Poster Image',
'value': self.config['incl_poster'],
'name': 'pushover_incl_poster',
'description': 'Include a poster with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'pushover_movie_provider',
'description': 'Select the source for movie links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers()
},
{'label': 'TV Show Link Source',
'value': self.config['tv_provider'],
'name': 'pushover_tv_provider',
'description': 'Select the source for tv show links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers()
},
{'label': 'Music Link Source',
'value': self.config['music_provider'],
'name': 'pushover_music_provider',
'description': 'Select the source for music links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers()
}
]
return config_option
class SCRIPTS(Notifier):
"""
Script notifications
"""
NAME = 'Script'
_DEFAULT_CONFIG = {'script_folder': '',
'script': '',
'timeout': 30
}
def __init__(self, config=None):
super(SCRIPTS, self).__init__(config=config)
self.script_exts = {
'.bat': '',
'.cmd': '',
'.php': 'php',
'.pl': 'perl',
'.ps1': 'powershell -executionPolicy bypass -file',
'.py': 'python' if plexpy.FROZEN else sys.executable,
'.pyw': 'pythonw',
'.rb': 'ruby',
'.sh': ''
}
self.pythonpath_override = 'nopythonpath'
self.pythonpath = True
self.prefix_overrides = {
'python': ['.py'],
'python2': ['.py'],
'python3': ['.py'],
'pythonw': ['.py', '.pyw']
}
self.script_killed = False
def list_scripts(self):
scriptdir = self.config['script_folder']
scripts = {'': ''}
if scriptdir and not os.path.exists(scriptdir):
return scripts
for root, dirs, files in os.walk(scriptdir):
for f in files:
name, ext = os.path.splitext(f)
if ext in self.script_exts:
rfp = os.path.join(os.path.relpath(root, scriptdir), f)
fp = os.path.join(root, f)
scripts[fp] = rfp
return scripts
def run_script(self, script, user_id):
# Common environment variables
custom_env = {
'PLEX_URL': plexpy.CONFIG.PMS_URL,
'PLEX_TOKEN': plexpy.CONFIG.PMS_TOKEN,
'PLEX_USER_TOKEN': '',
'TAUTULLI_URL': helpers.get_plexpy_url(hostname='localhost'),
'TAUTULLI_PUBLIC_URL': plexpy.CONFIG.HTTP_BASE_URL + plexpy.HTTP_ROOT,
'TAUTULLI_APIKEY': plexpy.CONFIG.API_KEY,
'TAUTULLI_ENCODING': plexpy.SYS_ENCODING,
'TAUTULLI_PYTHON_VERSION': common.PYTHON_VERSION,
'PLEXAPI_LOG_PATH': os.path.join(plexpy.CONFIG.LOG_DIR, 'plexapi_script.log')
}
if user_id:
user_tokens = users.Users().get_tokens(user_id=user_id)
custom_env['PLEX_USER_TOKEN'] = str(user_tokens['server_token'])
if self.pythonpath and plexpy.INSTALL_TYPE not in ('windows', 'macos'):
custom_env['PYTHONPATH'] = os.pathsep.join([p for p in sys.path if p])
env = os.environ.copy()
env.update(custom_env)
try:
process = subprocess.Popen(script,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=self.config['script_folder'],
env=env)
if self.config['timeout'] > 0:
timer = threading.Timer(self.config['timeout'], self.kill_script, (process,))
else:
timer = None
try:
if timer:
timer.start()
output, error = process.communicate()
status = process.returncode
logger.debug("Tautulli Notifiers :: Subprocess returned with status code %s." % status)
finally:
if timer:
timer.cancel()
except OSError as e:
logger.error("Tautulli Notifiers :: Failed to run script: %s" % e)
return False
if error:
err = '\n '.join(error.decode('utf-8').splitlines())
logger.error("Tautulli Notifiers :: Script error: \n %s" % err)
if output:
out = '\n '.join(output.decode('utf-8').splitlines())
logger.debug("Tautulli Notifiers :: Script returned: \n %s" % out)
if not self.script_killed:
logger.info("Tautulli Notifiers :: Script notification sent.")
return True
def kill_script(self, process):
process.kill()
self.script_killed = True
logger.warn("Tautulli Notifiers :: Script exceeded timeout limit of %d seconds. "
"Script killed." % self.config['timeout'])
def agent_notify(self, subject='', body='', action='', **kwargs):
"""
Args:
subject(string, optional): Subject text,
body(string, optional): Body text,
action(string): 'play'
"""
if not self.config['script_folder']:
logger.error("Tautulli Notifiers :: No script folder specified.")
return
script = kwargs.get('script', self.config.get('script', ''))
script_args = helpers.split_args(kwargs.get('script_args', subject))
user_id = kwargs.get('parameters', {}).get('user_id')
logger.debug("Tautulli Notifiers :: Trying to run notify script: %s, arguments: %s, action: %s"
% (script, script_args, action))
# Don't try to run the script if the action does not have one
if action and not script:
logger.debug("Tautulli Notifiers :: No script selected for action '%s', exiting..." % action)
return
elif not script:
logger.debug("Tautulli Notifiers :: No script selected, exiting...")
return
# Check for a valid script file
elif not os.path.isfile(script) or not script.endswith(tuple(self.script_exts)):
logger.error("Tautulli Notifiers :: Invalid script file '%s' specified, exiting..." % script)
return
name, ext = os.path.splitext(script)
prefix = self.script_exts.get(ext, '')
if prefix:
script = prefix.split() + [script]
else:
script = [script]
# Allow overrides for PYTHONPATH
if prefix and script_args:
if script_args[0] == self.pythonpath_override:
self.pythonpath = False
del script_args[0]
# Allow overrides for shitty systems
if prefix and script_args and script_args[0] in self.prefix_overrides:
if ext in self.prefix_overrides[script_args[0]]:
script[0] = script_args[0]
del script_args[0]
else:
logger.error("Tautulli Notifiers :: Invalid prefix override '%s' for '%s' script, exiting..."
% (script_args[0], ext))
return
script.extend(script_args)
logger.debug("Tautulli Notifiers :: Full script is: %s" % script)
logger.debug("Tautulli Notifiers :: Executing script in a new thread.")
thread = threading.Thread(target=self.run_script, args=(script, user_id)).start()
return True
def _return_config_options(self):
config_option = [{'label': 'Supported File Types',
'description': '<span class="inline-pre">' + \
', '.join(self.script_exts) + '</span>',
'input_type': 'help'
},
{'label': 'Script Folder',
'value': self.config['script_folder'],
'name': 'scripts_script_folder',
'description': 'Enter the full path to your script folder.',
'input_type': 'text',
'refresh': True
},
{'label': 'Script File',
'value': self.config['script'],
'name': 'scripts_script',
'description': 'Select the script file to run.',
'input_type': 'select',
'select_options': self.list_scripts()
},
{'label': 'Script Timeout',
'value': self.config['timeout'],
'name': 'scripts_timeout',
'description': 'The number of seconds to wait before killing the script. 0 to disable timeout.',
'input_type': 'number'
}
]
return config_option
class SLACK(Notifier):
"""
Slack Notifications
"""
NAME = 'Slack'
_DEFAULT_CONFIG = {'hook': '',
'channel': '',
'username': '',
'icon_emoji': '',
'color': '',
'incl_subject': 1,
'incl_card': 0,
'incl_description': 1,
'incl_thumbnail': 0,
'incl_pmslink': 0,
'movie_provider': '',
'tv_provider': '',
'music_provider': ''
}
def agent_notify(self, subject='', body='', action='', **kwargs):
if self.config['incl_subject']:
text = subject + '\n' + body
else:
text = body
data = {'text': text}
if self.config['channel'] and self.config['channel'].startswith('#'):
data['channel'] = self.config['channel']
if self.config['username']:
data['username'] = self.config['username']
if self.config['icon_emoji']:
if urlparse(self.config['icon_emoji']).scheme == '':
data['icon_emoji'] = self.config['icon_emoji']
else:
data['icon_url'] = self.config['icon_emoji']
if self.config['incl_card'] and kwargs.get('parameters', {}).get('media_type'):
# Grab formatted metadata
pretty_metadata = PrettyMetadata(kwargs['parameters'])
if pretty_metadata.media_type == 'movie':
provider = self.config['movie_provider']
elif pretty_metadata.media_type in ('show', 'season', 'episode'):
provider = self.config['tv_provider']
elif pretty_metadata.media_type in ('artist', 'album', 'track'):
provider = self.config['music_provider']
else:
provider = None
poster_url = pretty_metadata.get_poster_url()
provider_name = pretty_metadata.get_provider_name(provider)
provider_link = pretty_metadata.get_provider_link(provider)
title = pretty_metadata.get_title('\u00B7')
description = pretty_metadata.get_description()
plex_url = pretty_metadata.get_plex_url()
if provider_link:
text = f"*<{provider_link}|{title}>*"
else:
text = f"*{title}*"
if self.config['incl_description']:
text = f'{text}\n{description}'
# Max length of text is 3000 characters
text = (text[:2997] + (text[2997:] and '...'))
section = {
'type': 'section',
'text': {
'type': 'mrkdwn',
'text': text,
}
}
if self.config['incl_thumbnail']:
section['accessory'] = {
'type': 'image',
'image_url': poster_url,
'alt_text': title,
}
blocks = [section]
fields = []
field_title = {
'type': 'mrkdwn',
'text': 'View Details',
}
if provider_link:
fields.append(field_title)
fields.append({
'type': 'mrkdwn',
'text': f'<{provider_link}|{provider_name}>',
})
if self.config['incl_pmslink']:
fields.append(field_title)
fields.append({
'type': 'mrkdwn',
'text': f'<{plex_url}|Plex Web>',
})
if fields:
if len(fields) <= 2:
fields.append({
'type': 'plain_text',
'text': ' ',
})
blocks.append({
'type': 'section',
'fields': fields[::2] + fields[1::2],
})
if not self.config['incl_thumbnail']:
blocks.append({
'type': 'image',
'image_url': poster_url,
'alt_text': title,
})
attachment = {
'blocks': blocks,
}
if self.config['color'] and re.match(r'^#(?:[0-9a-fA-F]{3}){1,2}$', self.config['color']):
attachment['color'] = self.config['color']
data['attachments'] = [attachment]
headers = {'Content-type': 'application/json'}
return self.make_request(self.config['hook'], headers=headers, json=data)
def _return_config_options(self):
config_option = [{'label': 'Slack Webhook URL',
'value': self.config['hook'],
'name': 'slack_hook',
'description': 'Your Slack incoming webhook URL.',
'input_type': 'token'
},
{'label': 'Slack Channel',
'value': self.config['channel'],
'name': 'slack_channel',
'description': 'The Slack channel name (starting with \'#\') which will be used. Leave blank for webhook integration default.',
'input_type': 'text'
},
{'label': 'Slack Username',
'value': self.config['username'],
'name': 'slack_username',
'description': 'The Slack username which will be used. Leave blank for webhook integration default.',
'input_type': 'text'
},
{'label': 'Slack Icon',
'value': self.config['icon_emoji'],
'description': 'The Slack emoji or image url for the icon which will be used. Leave blank for webhook integration default.',
'name': 'slack_icon_emoji',
'input_type': 'text'
},
{'label': 'Slack Color',
'value': self.config['color'],
'description': 'The hex color value (starting with \'#\') for the border along the left side of the message attachment.',
'name': 'slack_color',
'input_type': 'text'
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'slack_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Rich Metadata Info',
'value': self.config['incl_card'],
'name': 'slack_incl_card',
'description': 'Include an info card with a poster and metadata with the notifications.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" '
'data-target="notify_upload_posters">Image Hosting</a> '
'must be enabled under the 3rd Party APIs settings tab.',
'input_type': 'checkbox'
},
{'label': 'Include Summary',
'value': self.config['incl_description'],
'name': 'slack_incl_description',
'description': 'Include a summary for the media on the info card.',
'input_type': 'checkbox'
},
{'label': 'Include Link to Plex Web',
'value': self.config['incl_pmslink'],
'name': 'slack_incl_pmslink',
'description': 'Include a second link to the media in Plex Web on the info card.',
'input_type': 'checkbox'
},
{'label': 'Use Poster Thumbnail',
'value': self.config['incl_thumbnail'],
'name': 'slack_incl_thumbnail',
'description': 'Use a thumbnail instead of a full sized poster on the info card.',
'input_type': 'checkbox'
},
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'slack_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers()
},
{'label': 'TV Show Link Source',
'value': self.config['tv_provider'],
'name': 'slack_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers()
},
{'label': 'Music Link Source',
'value': self.config['music_provider'],
'name': 'slack_music_provider',
'description': 'Select the source for music links on the info cards. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers()
}
]
return config_option
class TAUTULLIREMOTEAPP(Notifier):
"""
Tautulli Remote app notifications
"""
NAME = 'Tautulli Remote App'
_DEFAULT_CONFIG = {'device_id': '',
'priority': 3,
'notification_type': 0
}
def agent_notify(self, subject='', body='', action='', notification_id=None, **kwargs):
# Check mobile device is still registered
device = mobile_app.get_mobile_devices(device_id=self.config['device_id'])
if not device:
logger.warn("Tautulli Notifiers :: Unable to send Tautulli Remote app notification: device not registered.")
return
else:
device = device[0]
pretty_metadata = PrettyMetadata(kwargs.get('parameters'))
plaintext_data = {'notification_id': notification_id,
'subject': subject,
'body': body,
'action': action,
'priority': self.config['priority'],
'notification_type': self.config['notification_type'],
'session_key': pretty_metadata.parameters.get('session_key', ''),
'session_id': pretty_metadata.parameters.get('session_id', ''),
'user_id': pretty_metadata.parameters.get('user_id', ''),
'rating_key': pretty_metadata.parameters.get('rating_key', ''),
'poster_thumb': pretty_metadata.parameters.get('poster_thumb', '')}
#logger.debug("Plaintext data: {}".format(plaintext_data))
if _CRYPTOGRAPHY:
# Key generation
salt = os.urandom(16)
passphrase = device['device_token']
key_length = 32 # AES256
iterations = 600000
kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=key_length, salt=salt, iterations=iterations)
key = kdf.derive(passphrase.encode())
#logger.debug("Encryption key (base64): {}".format(base64.b64encode(key)))
# Encrypt using AES GCM
nonce = os.urandom(16)
cipher = AESGCM(key)
encrypted_data = cipher.encrypt(nonce, json.dumps(plaintext_data).encode('utf-8'), None)
#logger.debug("Encrypted data (base64): {}".format(base64.b64encode(encrypted_data)))
#logger.debug("GCM tag (base64): {}".format(base64.b64encode(gcm_tag)))
#logger.debug("Nonce (base64): {}".format(base64.b64encode(nonce)))
#logger.debug("Salt (base64): {}".format(base64.b64encode(salt)))
payload = {'app_id': mobile_app._ONESIGNAL_APP_ID,
'include_subscription_ids': [device['onesignal_id']],
'contents': {'en': 'Tautulli Notification'},
'data': {'encrypted': True,
'version': 2,
'cipher_text': base64.b64encode(encrypted_data),
'nonce': base64.b64encode(nonce),
'salt': base64.b64encode(salt),
'server_id': plexpy.CONFIG.PMS_UUID}
}
else:
logger.warn("Tautulli Notifiers :: Cryptography library is missing. "
"Tautulli Remote app notifications will be sent unecrypted. "
"Install the library to encrypt the notifications.")
payload = {'app_id': mobile_app._ONESIGNAL_APP_ID,
'include_subscription_ids': [device['onesignal_id']],
'contents': {'en': 'Tautulli Notification'},
'data': {'encrypted': False,
'plain_text': plaintext_data,
'server_id': plexpy.CONFIG.PMS_UUID}
}
#logger.debug("OneSignal payload: {}".format(payload))
headers = {'Content-Type': 'application/json'}
return self.make_request('https://api.onesignal.com/notifications', headers=headers, json=payload)
def get_devices(self):
db = database.MonitorDatabase()
try:
query = "SELECT * FROM mobile_devices WHERE official = 1 " \
"AND onesignal_id IS NOT NULL AND onesignal_id != ''"
return db.select(query=query)
except Exception as e:
logger.warn("Tautulli Notifiers :: Unable to retrieve Tautulli Remote app devices list: %s." % e)
return []
def _return_config_options(self):
config_option = []
if not _CRYPTOGRAPHY:
config_option.append({
'label': 'Warning',
'description': '<strong>The Cryptography library is missing. '
'The content of your notifications will be sent unencrypted!</strong><br>'
'Please install the library to encrypt the notification contents. '
'Instructions can be found in the '
'<a href="' + helpers.anon_url(
'https://github.com/%s/%s/wiki/Frequently-Asked-Questions#notifications-cryptography'
% (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO)) + '" target="_blank" rel="noreferrer">FAQ</a>.' ,
'input_type': 'help'
})
else:
config_option.append({
'label': 'Note',
'description': 'The Cryptography library was found. '
'The content of your notifications will be sent encrypted!',
'input_type': 'help'
})
config_option[-1]['description'] += ('<br><br>Notifications are sent using '
'<a href="' + helpers.anon_url('https://onesignal.com') + '" target="_blank" rel="noreferrer">'
'OneSignal</a>. Some user data is collected and cannot be encrypted.<br>'
'Please read the <a href="' + helpers.anon_url(
'https://onesignal.com/privacy_policy') + '" target="_blank" rel="noreferrer">'
'OneSignal Privacy Policy</a> for more details.')
devices = self.get_devices()
if not devices:
config_option.append({
'label': 'Device',
'description': 'No mobile devices registered with OneSignal. '
'<a data-tab-destination="remote_app" data-toggle="tab" data-dismiss="modal">'
'Get the Tautulli Remote App</a> and register a device.<br>'
'Note: Only devices registered with a valid OneSignal ID will appear in the list.',
'input_type': 'help'
})
else:
if len({d['platform'] for d in devices}) <= 1:
device_select = {d['device_id']: d['friendly_name'] or d['device_name'] for d in devices}
else:
device_select = defaultdict(list)
for d in devices:
platform = 'iOS' if d['platform'] == 'ios' else d['platform'].capitalize()
device_select[platform].append({
'value': d['device_id'],
'text': d['friendly_name'] or d['device_name']
})
config_option.append({
'label': 'Device',
'value': self.config['device_id'],
'name': 'remoteapp_device_id',
'description': 'Select your mobile device or '
'<a data-tab-destination="remote_app" data-toggle="tab" data-dismiss="modal">'
'register a new device</a> with Tautulli.<br>'
'Note: Only devices registered with a valid OneSignal ID will appear in the list.',
'input_type': 'select',
'select_options': device_select,
'refresh': True
})
platform = next((d['platform'] for d in devices if d['device_id'] == self.config['device_id']), None)
if platform == 'android':
config_option.append({
'label': 'Priority',
'value': self.config['priority'],
'name': 'remoteapp_priority',
'description': 'Set the notification priority.',
'input_type': 'select',
'select_options': {
1: 'Minimum',
2: 'Low',
3: 'Normal',
4: 'High'
}
})
config_option.append({
'label': 'Notification Image Type',
'value': self.config['notification_type'],
'name': 'remoteapp_notification_type',
'description': 'Set the notification image type.',
'input_type': 'select',
'select_options': {
0: 'No notification image',
1: 'Small image (Expandable text)',
2: 'Large image (Non-expandable text)'
}
})
elif platform == 'ios':
config_option.append({
'label': 'Include Poster Image',
'value': self.config['notification_type'],
'name': 'remoteapp_notification_type',
'description': 'Include a poster with the notifications.',
'input_type': 'checkbox'
})
return config_option
class TELEGRAM(Notifier):
"""
Telegram notifications
"""
NAME = 'Telegram'
_DEFAULT_CONFIG = {'bot_token': '',
'chat_id': '',
'disable_web_preview': 0,
'silent_notification': 0,
'html_support': 1,
'incl_subject': 1,
'incl_poster': 0
}
def agent_notify(self, subject='', body='', action='', **kwargs):
chat_id, *message_thread_id = self.config['chat_id'].split('/')
data = {'chat_id': chat_id}
if message_thread_id:
data['message_thread_id'] = message_thread_id[0]
if self.config['incl_subject']:
text = subject + '\r\n' + body
else:
text = body
if self.config['html_support']:
data['parse_mode'] = 'HTML'
if self.config['incl_poster'] and kwargs.get('parameters'):
# Grab formatted metadata
pretty_metadata = PrettyMetadata(kwargs['parameters'])
image = pretty_metadata.get_image()
if image:
files = {'photo': image}
if len(text) > 1024:
data['disable_notification'] = True
else:
data['caption'] = text.encode('utf-8')
if self.config['silent_notification']:
data['disable_notification'] = True
self.make_request('https://api.telegram.org/bot{}/sendPhoto'.format(self.config['bot_token']),
data=data, files=files)
if 'caption' in data:
return
data.pop('disable_notification', None)
data['text'] = (text[:4093] + (text[4093:] and '...')).encode('utf-8')
if self.config['disable_web_preview']:
data['disable_web_page_preview'] = True
if self.config['silent_notification']:
data['disable_notification'] = True
headers = {'Content-type': 'application/x-www-form-urlencoded'}
return self.make_request('https://api.telegram.org/bot{}/sendMessage'.format(self.config['bot_token']),
headers=headers, data=data)
def _return_config_options(self):
config_option = [{'label': 'Telegram Bot Token',
'value': self.config['bot_token'],
'name': 'telegram_bot_token',
'description': 'Your Telegram bot token. '
'Contact <a href="' + helpers.anon_url('https://telegram.me/BotFather') +
'" target="_blank" rel="noreferrer">@BotFather</a>'
' on Telegram to get one.',
'input_type': 'token'
},
{'label': 'Telegram Chat ID, Group ID, or Channel ID/Username',
'value': self.config['chat_id'],
'name': 'telegram_chat_id',
'description': 'Your Telegram Chat ID, Group ID, Channel ID or @channelusername. '
'Contact <a href="' + helpers.anon_url('https://telegram.me/myidbot') +
'" target="_blank" rel="noreferrer">@myidbot</a>'
' on Telegram to get an ID. '
'For a group topic, append <span class="inline-pre">/topicID</span> to the group ID.',
'input_type': 'text'
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'telegram_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Poster Image',
'value': self.config['incl_poster'],
'name': 'telegram_incl_poster',
'description': 'Include a poster with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Enable HTML Support',
'value': self.config['html_support'],
'name': 'telegram_html_support',
'description': 'Enable to style your messages using these HTML tags:<br>'
'b, strong, i, em, u, ins, s, strike, del, span[class], '
'tg-spoiler, a[href], code[class], pre',
'input_type': 'checkbox'
},
{'label': 'Disable Web Page Previews',
'value': self.config['disable_web_preview'],
'name': 'telegram_disable_web_preview',
'description': 'Disables automatic link previews for links in the message',
'input_type': 'checkbox'
},
{'label': 'Enable Silent Notifications',
'value': self.config['silent_notification'],
'name': 'telegram_silent_notification',
'description': 'Send notifications silently without any alert sounds.',
'input_type': 'checkbox'
}
]
return config_option
class TWITTER(Notifier):
"""
Twitter notifications
"""
NAME = 'Twitter'
REQUEST_TOKEN_URL = 'https://api.twitter.com/oauth/request_token'
ACCESS_TOKEN_URL = 'https://api.twitter.com/oauth/access_token'
AUTHORIZATION_URL = 'https://api.twitter.com/oauth/authorize'
SIGNIN_URL = 'https://api.twitter.com/oauth/authenticate'
_DEFAULT_CONFIG = {'access_token': '',
'access_token_secret': '',
'consumer_key': '',
'consumer_secret': '',
'incl_subject': 1,
'incl_poster': 0
}
def _send_tweet(self, message=None, attachment=None):
consumer_key = self.config['consumer_key']
consumer_secret = self.config['consumer_secret']
access_token = self.config['access_token']
access_token_secret = self.config['access_token_secret']
# logger.info("Tautulli Notifiers :: Sending tweet: " + message)
api = twitter.Api(consumer_key, consumer_secret, access_token, access_token_secret)
try:
api.PostUpdate(message, media=attachment)
logger.info("Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
return True
except Exception as e:
logger.error("Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e))
return False
def agent_notify(self, subject='', body='', action='', **kwargs):
poster_url = ''
if self.config['incl_poster'] and kwargs.get('parameters'):
parameters = kwargs['parameters']
poster_url = parameters.get('poster_url','')
# Hack to add media type to attachment
if poster_url and not helpers.get_img_service():
poster_url += '.png'
if self.config['incl_subject']:
return self._send_tweet(subject + '\r\n' + body, attachment=poster_url)
else:
return self._send_tweet(body, attachment=poster_url)
def _return_config_options(self):
config_option = [{'label': 'Twitter Consumer Key',
'value': self.config['consumer_key'],
'name': 'twitter_consumer_key',
'description': 'Your Twitter consumer key.',
'input_type': 'token'
},
{'label': 'Twitter Consumer Secret',
'value': self.config['consumer_secret'],
'name': 'twitter_consumer_secret',
'description': 'Your Twitter consumer secret.',
'input_type': 'token'
},
{'label': 'Twitter Access Token',
'value': self.config['access_token'],
'name': 'twitter_access_token',
'description': 'Your Twitter access token.',
'input_type': 'token'
},
{'label': 'Twitter Access Token Secret',
'value': self.config['access_token_secret'],
'name': 'twitter_access_token_secret',
'description': 'Your Twitter access token secret.',
'input_type': 'token'
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'twitter_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Poster Image',
'value': self.config['incl_poster'],
'name': 'twitter_incl_poster',
'description': 'Include a poster with the notifications.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" '
'data-target="notify_upload_posters">Image Hosting</a> '
'must be enabled under the 3rd Party APIs settings tab.',
'input_type': 'checkbox'
}
]
return config_option
class WEBHOOK(Notifier):
"""
Webhook notifications
"""
NAME = 'Webhook'
_DEFAULT_CONFIG = {'hook': '',
'method': 'POST'
}
def agent_notify(self, subject='', body='', action='', **kwargs):
subject = kwargs.get('headers', subject)
if subject:
try:
webhook_headers = json.loads(subject)
except ValueError as e:
logger.error("Tautulli Notifiers :: Invalid {name} json header data: {e}".format(name=self.NAME, e=e))
return False
else:
webhook_headers = None
if body:
try:
webhook_body = json.loads(body)
except ValueError as e:
logger.error("Tautulli Notifiers :: Invalid {name} json body data: {e}".format(name=self.NAME, e=e))
return False
else:
webhook_body = None
headers = {'Content-Type': 'application/json'}
if webhook_headers:
headers.update(webhook_headers)
if headers['Content-Type'] == 'application/json':
data = {'json': webhook_body}
else:
data = {'data': webhook_body}
return self.make_request(self.config['hook'], method=self.config['method'], headers=headers, **data)
def _return_config_options(self):
config_option = [{'label': 'Webhook URL',
'value': self.config['hook'],
'name': 'webhook_hook',
'description': 'Your Webhook URL.',
'input_type': 'token'
},
{'label': 'Webhook Method',
'value': self.config['method'],
'name': 'webhook_method',
'description': 'The Webhook HTTP request method.',
'input_type': 'select',
'select_options': {'GET': 'GET',
'POST': 'POST',
'PUT': 'PUT',
'DELETE': 'DELETE'}
}
]
return config_option
class XBMC(Notifier):
"""
Kodi notifications
"""
NAME = 'Kodi'
_DEFAULT_CONFIG = {'hosts': '',
'username': '',
'password': '',
'display_time': 5,
'image': ''
}
def _sendhttp(self, host, command):
url_command = urlencode(command)
url = host + '/xbmcCmds/xbmcHttp/?' + url_command
if self.config['password']:
return request.request_content(url, auth=(self.config['username'], self.config['password']))
else:
return request.request_content(url)
def _sendjson(self, host, method, params=None):
params = params or {}
data = [{'id': 0, 'jsonrpc': '2.0', 'method': method, 'params': params}]
headers = {'Content-Type': 'application/json'}
url = host + '/jsonrpc'
if self.config['password']:
response = request.request_json(url, method="post", data=json.dumps(data), headers=headers,
auth=(self.config['username'], self.config['password']))
else:
response = request.request_json(url, method="post", data=json.dumps(data), headers=headers)
if response:
return response[0]['result']
def agent_notify(self, subject='', body='', action='', **kwargs):
hosts = [x.strip() for x in self.config['hosts'].split(',')]
if self.config['display_time'] > 0:
display_time = 1000 * self.config['display_time'] # in ms
else:
display_time = 5000
if self.config['image']:
image = self.config['image']
else:
image = os.path.join(plexpy.DATA_DIR, os.path.abspath("data/interfaces/default/images/logo-circle.png"))
for host in hosts:
logger.info("Tautulli Notifiers :: Sending notification command to XMBC @ " + host)
try:
version = self._sendjson(host, 'Application.GetProperties', {'properties': ['version']})['version']['major']
if version < 12: # Eden
notification = subject + "," + body + "," + str(display_time)
notifycommand = {'command': 'ExecBuiltIn', 'parameter': 'Notification(' + notification + ')'}
request = self._sendhttp(host, notifycommand)
else: # Frodo
params = {'title': subject, 'message': body, 'displaytime': display_time, 'image': image}
request = self._sendjson(host, 'GUI.ShowNotification', params)
if not request:
raise Exception
else:
logger.info("Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
except Exception as e:
logger.error("Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e))
return False
return True
def _return_config_options(self):
config_option = [{'label': 'Kodi Host Address',
'value': self.config['hosts'],
'name': 'xbmc_hosts',
'description': 'Host running Kodi (e.g. http://localhost:8080). Separate multiple hosts with commas (,).',
'input_type': 'text'
},
{'label': 'Kodi Username',
'value': self.config['username'],
'name': 'xbmc_username',
'description': 'Username of your Kodi client API (blank for none).',
'input_type': 'text'
},
{'label': 'Kodi Password',
'value': self.config['password'],
'name': 'xbmc_password',
'description': 'Password of your Kodi client API (blank for none).',
'input_type': 'password'
},
{'label': 'Notification Duration',
'value': self.config['display_time'],
'name': 'xbmc_display_time',
'description': 'The duration (in seconds) for the notification to stay on screen.',
'input_type': 'number'
},
{'label': 'Notification Icon',
'value': self.config['image'],
'name': 'xbmc_image',
'description': 'Full path or URL to an image to display with the notification. Leave blank for the default.',
'input_type': 'text'
}
]
return config_option
class ZAPIER(Notifier):
"""
Zapier notifications
"""
NAME = 'Zapier'
_DEFAULT_CONFIG = {'hook': '',
'movie_provider': '',
'tv_provider': '',
'music_provider': ''
}
def _test_hook(self):
_test_data = {'subject': 'Subject',
'body': 'Body',
'action': 'Action',
'poster_url': 'https://i.imgur.com',
'provider_name': 'Provider Name',
'provider_link': 'http://www.imdb.com',
'plex_url': 'https://app.plex.tv/desktop'}
return self.agent_notify(_test_data=_test_data)
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'subject': subject,
'body': body,
'action': action}
if kwargs.get('parameters', {}).get('media_type'):
# Grab formatted metadata
pretty_metadata = PrettyMetadata(kwargs['parameters'])
if pretty_metadata.media_type == 'movie':
provider = self.config['movie_provider']
elif pretty_metadata.media_type in ('show', 'season', 'episode'):
provider = self.config['tv_provider']
elif pretty_metadata.media_type in ('artist', 'album', 'track'):
provider = self.config['music_provider']
else:
provider = None
poster_url = pretty_metadata.get_poster_url()
provider_name = pretty_metadata.get_provider_name(provider)
provider_link = pretty_metadata.get_provider_link(provider)
plex_url = pretty_metadata.get_plex_url()
data['poster_url'] = poster_url
data['provider_name'] = provider_name
data['provider_link'] = provider_link
data['plex_url'] = plex_url
if kwargs.get('_test_data'):
data.update(kwargs['_test_data'])
headers = {'Content-type': 'application/json'}
return self.make_request(self.config['hook'], headers=headers, json=data)
def _return_config_options(self):
config_option = [{'label': 'Zapier Webhook URL',
'value': self.config['hook'],
'name': 'zapier_hook',
'description': 'Your Zapier webhook URL.',
'input_type': 'token'
},
{'label': 'Test Zapier Webhook',
'value': 'Send Test Data',
'name': 'zapier_test_hook',
'description': 'Click this button when prompted on then "Test Webhooks by Zapier" step.',
'input_type': 'button'
},
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'zapier_movie_provider',
'description': 'Select the source for movie links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers()
},
{'label': 'TV Show Link Source',
'value': self.config['tv_provider'],
'name': 'zapier_tv_provider',
'description': 'Select the source for tv show links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers()
},
{'label': 'Music Link Source',
'value': self.config['music_provider'],
'name': 'zapier_music_provider',
'description': 'Select the source for music links in the notification. Leave blank to disable.<br>'
'Note: <a data-tab-destination="3rd_party_apis" data-dismiss="modal" >Metadata Lookups</a> '
'may need to be enabled under the 3rd Party APIs settings tab.',
'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers()
}
]
return config_option
def check_browser_enabled():
global BROWSER_NOTIFIERS
BROWSER_NOTIFIERS = {}
for n in get_notifiers():
if n['agent_id'] == 17 and n['active']:
notifier_config = get_notifier_config(n['id'])
BROWSER_NOTIFIERS[n['id']] = notifier_config['config']['auto_hide_delay']
def get_browser_notifications():
db = database.MonitorDatabase()
result = db.select("SELECT notifier_id, subject_text, body_text FROM notify_log "
"WHERE agent_id = 17 AND timestamp >= ? ",
args=[time.time() - 5])
notifications = []
for item in result:
notification = {'subject_text': item['subject_text'],
'body_text': item['body_text'],
'delay': BROWSER_NOTIFIERS.get(item['notifier_id'], 5)}
notifications.append(notification)
return {'notifications': notifications}