Compare commits

...

35 Commits

Author SHA1 Message Date
JonnyWong16
45c2ccdffe v2.0.14-beta 2018-01-20 11:42:36 -08:00
JonnyWong16
fc14c3165f Remove email line break message 2018-01-20 11:30:11 -08:00
JonnyWong16
0fad245148 Try to cleanly shutdown loggers 2018-01-20 11:27:58 -08:00
JonnyWong16
79609c384e Show all changelogs when updated since previous version 2018-01-20 10:27:08 -08:00
JonnyWong16
09054ddb4b Correct clear logs message 2018-01-19 19:11:55 -08:00
JonnyWong16
6f912d4aa2 Add date header to Emails and do not add HTML line breaks automatically 2018-01-19 15:54:58 -08:00
JonnyWong16
96033a8214 Rename Tautulli update notification parameters 2018-01-19 14:59:00 -08:00
JonnyWong16
5ca65f4797 Catch json ValueError in metadata cache 2018-01-19 07:13:53 -08:00
JonnyWong16
d2fccbde68 Json dump custom conditions 2018-01-18 14:02:47 -08:00
JonnyWong16
e6b48d7baf Check for browser proxy compatibility 2018-01-17 21:02:31 -08:00
JonnyWong16
3e51310511 Re-enable browser notifications 2018-01-17 17:01:44 -08:00
JonnyWong16
32b43202c2 Attempt at fixing stuck sessions which require flishing the database 2018-01-15 18:55:37 -08:00
JonnyWong16
446170f8de Reduce websocket logging to playing and timeline only 2018-01-15 17:59:12 -08:00
JonnyWong16
c5a9ecd4ac Make sure websocket events are for library items 2018-01-15 14:49:49 -08:00
JonnyWong16
2af5f817a3 Plex Web url for tracks should go to the album page 2018-01-15 14:37:40 -08:00
JonnyWong16
4e55cf3cd4 Add all other bandwidth to WAN 2018-01-15 14:25:42 -08:00
JonnyWong16
eeb0478813 Use font-awesome arrow on activity cards 2018-01-14 20:45:27 -08:00
JonnyWong16
33739f1cb2 Fix check activity session write success 2018-01-13 21:16:07 -08:00
JonnyWong16
515e6a8071 Sort selectize when rendered 2018-01-13 17:47:24 -08:00
JonnyWong16
2b22f8eb4f Add select/remove all options for emails 2018-01-13 17:18:09 -08:00
JonnyWong16
e9725a0081 v2.0.13-beta 2018-01-13 12:34:57 -08:00
JonnyWong16
8fd159d2fe Use added_at to check recently added more than 24 hours ago 2018-01-12 12:34:07 -08:00
JonnyWong16
3d7e6c8b2c Update circle logo 2018-01-12 09:01:48 -08:00
JonnyWong16
0c048d61b1 Add message that Imgur upload may be required 2018-01-12 01:00:58 -08:00
JonnyWong16
f05b8e5cd1 Add error message when notification subject/body is required 2018-01-12 00:48:53 -08:00
JonnyWong16
0b38fec827 Add more options for Join notifications 2018-01-12 00:35:37 -08:00
JonnyWong16
547dc9ed33 Fix checking login permissions 2018-01-11 23:28:10 -08:00
JonnyWong16
896a37bea9 Allow Plex admin to login as Tautulli admin 2018-01-11 22:58:30 -08:00
JonnyWong16
3f90037db3 Make custom conditions message clearer 2018-01-11 22:52:52 -08:00
JonnyWong16
380ca11ced Fix some parameter types 2018-01-11 22:07:29 -08:00
JonnyWong16
ab3a288e49 Add "or" between condition values 2018-01-10 18:31:14 -08:00
JonnyWong16
638e225f80 Add selectize resources 2018-01-10 00:36:01 -08:00
JonnyWong16
5089ede207 Add selectize for email input 2018-01-10 00:32:24 -08:00
JonnyWong16
a3e6e76158 Implement JWT instead of using cherrypy sessions 2018-01-08 22:25:52 -08:00
JonnyWong16
7c4c7bfc90 Add pyjwt 1.4.0 2018-01-08 22:24:36 -08:00
43 changed files with 2071 additions and 445 deletions

1
.gitignore vendored
View File

@@ -12,6 +12,7 @@
*.db* *.db*
*.db-journal *.db-journal
*.ini *.ini
release.lock
version.lock version.lock
logs/* logs/*
cache/* cache/*

View File

@@ -1,5 +1,33 @@
# Changelog # Changelog
## v2.0.14-beta (2018-01-20)
* Monitoring:
* Change: Added "Cellular" bandwidth to "WAN" in activity header.
* Notifications:
* Fix: Plex Web URL for tracks now go to the album page.
* Fix: Recently added notifications being sent for the entire library when DVR EPG data was refreshed.
* Fix: Notifier settings not loading with an apostrophe in the custom condition values.
* Fix: Custom email addresses not being saved when closing the notifier settings.
* Change: Re-enabled Browser notifications.
* Change: Renamed "PlexPy" update notification parameters to "Tautulli".
* Change: Emails no longer automatically insert HTML line breaks.
* Change: "Date" header added to email notifications.
* UI:
* Change: Show all changelogs since the previous version when updating.
## v2.0.13-beta (2018-01-13)
* Notifications:
* New: Added dropdown selection for email addresses of shared users.
* New: Added more notification options for Join.
* Change: Show "OR" between custom condition values.
* Other:
* New: Use JSON Web Tokens for authentication. Login now works with SSO applications.
* New: Allow the Plex server admin to login as a Tautulli admin using their Plex.tv account.
## v2.0.12-beta (2018-01-07) ## v2.0.12-beta (2018-01-07)
* Notifications: * Notifications:

View File

@@ -2,6 +2,7 @@
import plexpy import plexpy
from plexpy import version from plexpy import version
from plexpy.helpers import anon_url from plexpy.helpers import anon_url
from plexpy.notifiers import BROWSER_NOTIFIERS
%> %>
<!doctype html> <!doctype html>
@@ -138,7 +139,7 @@
<li><a href="#" data-target="#admin-login-modal" data-toggle="modal"><i class="fa fa-fw fa-lock"></i> Admin Login</a></li> <li><a href="#" data-target="#admin-login-modal" data-toggle="modal"><i class="fa fa-fw fa-lock"></i> Admin Login</a></li>
<li role="separator" class="divider"></li> <li role="separator" class="divider"></li>
% endif % endif
% if _session['expiry']: % if _session['exp']:
<li><a href="${http_root}auth/logout"><i class="fa fa-fw fa-sign-out"></i> Sign Out</a></li> <li><a href="${http_root}auth/logout"><i class="fa fa-fw fa-sign-out"></i> Sign Out</a></li>
% endif % endif
</ul> </ul>
@@ -161,7 +162,7 @@ ${next.modalIncludes()}
<div id="admin-login-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="admin-login-modal"> <div id="admin-login-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="admin-login-modal">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<form action="${http_root}auth/login" method="post"> <form id="login-form">
<div class="modal-header"> <div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button> <button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
<h4 class="modal-title">Admin Login</h4> <h4 class="modal-title">Admin Login</h4>
@@ -190,7 +191,8 @@ ${next.modalIncludes()}
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button> <span id="incorrect-login" style="padding-right: 25px; display: none;">Incorrect username or password.</span>
<button id="sign-in" type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button>
</div> </div>
<input type="hidden" id="admin_login" name="admin_login" value="1" /> <input type="hidden" id="admin_login" name="admin_login" value="1" />
</form> </form>
@@ -282,6 +284,9 @@ ${next.modalIncludes()}
<script src="${http_root}js/pnotify.custom.min.js"></script> <script src="${http_root}js/pnotify.custom.min.js"></script>
<script src="${http_root}js/script.js${cache_param}"></script> <script src="${http_root}js/script.js${cache_param}"></script>
<script src="${http_root}js/jquery.qrcode.min.js"></script> <script src="${http_root}js/jquery.qrcode.min.js"></script>
% if _session['user_group'] == 'admin' and BROWSER_NOTIFIERS:
<script src="${http_root}js/ajaxNotifications.js"></script>
% endif
<script> <script>
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':
$('#updateDismiss').click(function() { $('#updateDismiss').click(function() {
@@ -386,6 +391,29 @@ ${next.modalIncludes()}
$('#admin-login-modal').on('shown.bs.modal', function () { $('#admin-login-modal').on('shown.bs.modal', function () {
$('#admin-login-modal #username').focus() $('#admin-login-modal #username').focus()
}) })
$('#login-form').submit(function(event) {
event.preventDefault();
$('#sign-in').prop('disabled', true).html('<i class="fa fa-refresh fa-spin"></i>&nbsp; Sign In');
$.ajax({
url: '${http_root}auth/signin',
type: 'POST',
data: $(this).serialize(),
dataType: 'json',
statusCode: {
200: function() {
window.location = "${http_root}";
},
401: function() {
$('#incorrect-login').show();
$('#username').focus();
}
},
complete: function() {
$('#sign-in').prop('disabled', false).html('<i class="fa fa-sign-in"></i>&nbsp; Sign In');
}
});
});
% endif % endif
</script> </script>
${next.javascriptIncludes()} ${next.javascriptIncludes()}

View File

@@ -79,6 +79,13 @@ select.form-control {
padding: 1px 2px; padding: 1px 2px;
transition: background-color .3s; transition: background-color .3s;
} }
.selectize-control.form-control .selectize-input {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 4px;
padding-left: 5px;
}
.react-selectize.root-node .react-selectize-control .react-selectize-placeholder { .react-selectize.root-node .react-selectize-control .react-selectize-placeholder {
color: #fff !important; color: #fff !important;
} }
@@ -95,7 +102,13 @@ select.form-control {
.react-selectize.root-node .simple-value span { .react-selectize.root-node .simple-value span {
padding-bottom: 2px !important; padding-bottom: 2px !important;
} }
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .resizable-input{ .react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .value-wrapper:not(:first-child):before {
content: "or";
padding: 0 3px;
text-transform: uppercase;
font-size: 10px;
}
.react-selectize.root-node .react-selectize-control .react-selectize-search-field-and-selected-values .resizable-input {
padding-top: 3px !important; padding-top: 3px !important;
padding-bottom: 3px !important; padding-bottom: 3px !important;
} }
@@ -110,7 +123,7 @@ select.form-control:focus,
} }
.react-selectize.root-node.open .simple-value, .react-selectize.root-node.open .simple-value,
.selectize-control.multi .selectize-input.focus > div, .selectize-control.multi .selectize-input.focus > div,
.selectize-control.multi .selectize-input > div.active{ .selectize-control.multi .selectize-input > div.active {
background: #efefef !important; background: #efefef !important;
color: #333333 !important; color: #333333 !important;
transition: background-color .3s; transition: background-color .3s;
@@ -118,6 +131,47 @@ select.form-control:focus,
.react-selectize.root-node.open .react-selectize-control .react-selectize-toggle-button path { .react-selectize.root-node.open .react-selectize-control .react-selectize-toggle-button path {
fill: #999 !important; fill: #999 !important;
} }
.selectize-control .selectize-input > div .item-value {
opacity: 0.8;
font-size: 12px;
}
.selectize-control .selectize-input > div .item-text + .item-value {
margin-left: 5px;
}
.selectize-control .selectize-input > div .item-value:before {
content: '<';
opacity: 0.8;
font-size: 12px;
}
.selectize-control .selectize-input > div .item-value:after {
content: '>';
opacity: 0.8;
font-size: 12px;
}
.selectize-control .selectize-dropdown .caption {
font-size: 12px;
display: block;
color: #a0a0a0;
}
.selectize-control .selectize-dropdown .select-all,
.selectize-control .selectize-dropdown .remove-all {
font-weight: bold;
}
.selectize-control .selectize-dropdown .border-all {
pointer-events: none;
display: block;
height: 1px;
margin: 9px -12px 9px -12px;
padding: 0 !important;
overflow: hidden;
background-color: #e5e5e5;
}
.selectize-control .selectize-dropdown .border-all:last-child {
display: none;
}
.selectize-dropdown .optgroup-header {
font-weight: bold;
}
select.form-control option { select.form-control option {
color: #555; color: #555;
background-color: #fff; background-color: #fff;
@@ -166,7 +220,7 @@ object {
} }
.nav .open > a, .nav .open > a:hover, .nav .open > a:focus { .nav .open > a, .nav .open > a:hover, .nav .open > a:focus {
background-color: #2f2f2f; background-color: #2f2f2f;
border-color: none; border-color: unset;
} }
.dropdown-menu { .dropdown-menu {
background-color: #282828; background-color: #282828;
@@ -934,7 +988,6 @@ a .users-poster-face:hover {
background-image: -o-linear-gradient(top, #fbb450, #f89406); background-image: -o-linear-gradient(top, #fbb450, #f89406);
background-image: linear-gradient(to bottom, #fbb450, #f89406); background-image: linear-gradient(to bottom, #fbb450, #f89406);
background-repeat: repeat-x; background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fffbb450', endColorstr='#fff89406', GradientType=0);
position: absolute; position: absolute;
height: 100%; height: 100%;
max-width: 100%; max-width: 100%;
@@ -1689,7 +1742,6 @@ a:hover .dashboard-recent-media-cover {
background-image: -moz-linear-gradient(top,rgba(0,0,0,.7) 0,rgba(0,0,0,.9) 100%); background-image: -moz-linear-gradient(top,rgba(0,0,0,.7) 0,rgba(0,0,0,.9) 100%);
background-image: linear-gradient(to bottom,rgba(0,0,0,.7) 0,rgba(0,0,0,.9) 100%); background-image: linear-gradient(to bottom,rgba(0,0,0,.7) 0,rgba(0,0,0,.9) 100%);
background-repeat: repeat-x; background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#b3000000', endColorstr='#e6000000', GradientType=0);
webkit-box-shadow: inset 0 0 0 2px #e9a049; webkit-box-shadow: inset 0 0 0 2px #e9a049;
-moz-box-shadow: inset 0 0 0 2px #e9a049; -moz-box-shadow: inset 0 0 0 2px #e9a049;
box-shadow: inset 0 0 0 2px #e9a049; box-shadow: inset 0 0 0 2px #e9a049;

View File

@@ -202,7 +202,7 @@ DOCUMENTATION :: END
<div class="sub-heading">Container</div> <div class="sub-heading">Container</div>
<div class="sub-value" id="transcode_container-${sk}"> <div class="sub-value" id="transcode_container-${sk}">
% if data.get('stream_container_decision') == 'transcode': % if data.get('stream_container_decision') == 'transcode':
Transcode (${data['container'].upper()} &rarr; ${data['stream_container'].upper()}) Transcode (${data['container'].upper()} <i class="fa fa-long-arrow-right"></i> ${data['stream_container'].upper()})
% else: % else:
Direct Play (${data['container'].upper()}) Direct Play (${data['container'].upper()})
% endif % endif
@@ -218,7 +218,7 @@ DOCUMENTATION :: END
hw_d = ' (HW)' if data['transcode_hw_decoding'] else '' hw_d = ' (HW)' if data['transcode_hw_decoding'] else ''
hw_e = ' (HW)' if data['transcode_hw_encoding'] else '' hw_e = ' (HW)' if data['transcode_hw_encoding'] else ''
%> %>
Transcode (${data['video_codec'].upper()}${hw_d} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])} &rarr; ${data['stream_video_codec'].upper()}${hw_e} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])}) Transcode (${data['video_codec'].upper()}${hw_d} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])} <i class="fa fa-long-arrow-right"></i> ${data['stream_video_codec'].upper()}${hw_e} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
% elif data.get('stream_video_decision') == 'copy': % elif data.get('stream_video_decision') == 'copy':
Direct Stream (${data['stream_video_codec'].upper()} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])}) Direct Stream (${data['stream_video_codec'].upper()} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
% else: % else:
@@ -235,7 +235,7 @@ DOCUMENTATION :: END
<div class="sub-heading">Audio</div> <div class="sub-heading">Audio</div>
<div class="sub-value" id="audio_decision-${sk}"> <div class="sub-value" id="audio_decision-${sk}">
% if data.get('stream_audio_decision') == 'transcode': % if data.get('stream_audio_decision') == 'transcode':
Transcode (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} &rarr; ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()}) Transcode (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} <i class="fa fa-long-arrow-right"></i> ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% elif data.get('stream_audio_decision') == 'copy': % elif data.get('stream_audio_decision') == 'copy':
Direct Stream (${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()}) Direct Stream (${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% else: % else:
@@ -250,7 +250,7 @@ DOCUMENTATION :: END
<div class="sub-value" id="subtitle_decision-${sk}"> <div class="sub-value" id="subtitle_decision-${sk}">
% if data['subtitles'] == 1: % if data['subtitles'] == 1:
% if data['stream_subtitle_decision'] == 'transcode': % if data['stream_subtitle_decision'] == 'transcode':
Transcode (${data['subtitle_codec'].upper()} &rarr; ${data['stream_subtitle_codec'].upper()}) Transcode (${data['subtitle_codec'].upper()} <i class="fa fa-long-arrow-right"></i> ${data['stream_subtitle_codec'].upper()})
% elif data['stream_subtitle_decision'] == 'copy': % elif data['stream_subtitle_decision'] == 'copy':
Direct Stream (${data['subtitle_codec'].upper()}) Direct Stream (${data['subtitle_codec'].upper()})
% elif data['stream_subtitle_decision'] == 'burn': % elif data['stream_subtitle_decision'] == 'burn':

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -131,12 +131,13 @@
<%def name="modalIncludes()"> <%def name="modalIncludes()">
% if _session['user_group'] == 'admin' and config['update_show_changelog']: % if _session['user_group'] == 'admin' and config['update_show_changelog']:
<% from plexpy.common import VERSION_NUMBER %>
<div id="changelog-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="changelog-modal"> <div id="changelog-modal" class="modal fade wide" tabindex="-1" role="dialog" aria-labelledby="changelog-modal">
<div class="modal-dialog" role="document"> <div class="modal-dialog" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button> <button type="button" class="close" data-dismiss="modal" aria-hidden="true"><i class="fa fa-remove"></i></button>
<h4 class="modal-title">Tautulli Updated</h4> <h4 class="modal-title">Tautulli Updated to <strong>${VERSION_NUMBER}</strong></h4>
</div> </div>
<div class="modal-body"> <div class="modal-body">
</div> </div>
@@ -321,7 +322,7 @@
$('#currentActivityHeader').show(); $('#currentActivityHeader').show();
sessions.forEach(function (session) { sessions.forEach(function (session) {
var s = new Proxy(session, defaultHandler); var s = (typeof Proxy === "function") ? new Proxy(session, defaultHandler) : session;
var key = s.session_key; var key = s.session_key;
var session_id = s.session_id; var session_id = s.session_id;
var instance = $('#activity-instance-' + key); var instance = $('#activity-instance-' + key);
@@ -395,7 +396,7 @@
var transcode_container = ''; var transcode_container = '';
if (s.stream_container_decision === 'transcode') { if (s.stream_container_decision === 'transcode') {
transcode_container = 'Transcode (' + s.container.toUpperCase() + ' &rarr; ' + s.stream_container.toUpperCase() + ')'; transcode_container = 'Transcode (' + s.container.toUpperCase() + ' <i class="fa fa-long-arrow-right"></i> ' + s.stream_container.toUpperCase() + ')';
} else { } else {
transcode_container = 'Direct Play (' + s.container.toUpperCase() + ')'; transcode_container = 'Direct Play (' + s.container.toUpperCase() + ')';
} }
@@ -428,7 +429,7 @@
if (s.stream_video_decision === 'transcode') { if (s.stream_video_decision === 'transcode') {
var hw_d = (s.transcode_hw_decoding === 1) ? ' (HW)' : ''; var hw_d = (s.transcode_hw_decoding === 1) ? ' (HW)' : '';
var hw_e = (s.transcode_hw_encoding === 1) ? ' (HW)' : ''; var hw_e = (s.transcode_hw_encoding === 1) ? ' (HW)' : '';
video_decision = 'Transcode (' + s.video_codec.toUpperCase() + hw_d + ' ' + v_res + ' &rarr; ' + s.stream_video_codec.toUpperCase() + hw_e + ' ' + sv_res + ')'; video_decision = 'Transcode (' + s.video_codec.toUpperCase() + hw_d + ' ' + v_res + ' <i class="fa fa-long-arrow-right"></i> ' + s.stream_video_codec.toUpperCase() + hw_e + ' ' + sv_res + ')';
} else if (s.stream_video_decision === 'copy') { } else if (s.stream_video_decision === 'copy') {
video_decision = 'Direct Stream (' + s.stream_video_codec.toUpperCase() + ' ' + sv_res + ')'; video_decision = 'Direct Stream (' + s.stream_video_codec.toUpperCase() + ' ' + sv_res + ')';
} else { } else {
@@ -444,7 +445,7 @@
var a_codec = (s.audio_codec === 'truehd') ? 'TrueHD' : s.audio_codec.toUpperCase(); var a_codec = (s.audio_codec === 'truehd') ? 'TrueHD' : s.audio_codec.toUpperCase();
var sa_codec = (s.stream_audio_codec === 'truehd') ? 'TrueHD' : s.stream_audio_codec.toUpperCase(); var sa_codec = (s.stream_audio_codec === 'truehd') ? 'TrueHD' : s.stream_audio_codec.toUpperCase();
if (s.stream_audio_decision === 'transcode') { if (s.stream_audio_decision === 'transcode') {
audio_decision = 'Transcode (' + a_codec + ' ' + capitalizeFirstLetter(s.audio_channel_layout.split('(')[0]) + ' &rarr; ' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')'; audio_decision = 'Transcode (' + a_codec + ' ' + capitalizeFirstLetter(s.audio_channel_layout.split('(')[0]) + ' <i class="fa fa-long-arrow-right"></i> ' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
} else if (s.stream_audio_decision === 'copy') { } else if (s.stream_audio_decision === 'copy') {
audio_decision = 'Direct Stream (' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')'; audio_decision = 'Direct Stream (' + sa_codec + ' ' + capitalizeFirstLetter(s.stream_audio_channel_layout.split('(')[0]) + ')';
} else { } else {
@@ -456,7 +457,7 @@
var subtitle_decision = 'None'; var subtitle_decision = 'None';
if (['movie', 'episode', 'clip'].indexOf(s.media_type) > -1 && s.subtitles === 1) { if (['movie', 'episode', 'clip'].indexOf(s.media_type) > -1 && s.subtitles === 1) {
if (s.stream_subtitle_decision === 'transcode') { if (s.stream_subtitle_decision === 'transcode') {
subtitle_decision = 'Transcode (' + s.subtitle_codec.toUpperCase() + ' &rarr; ' + s.stream_subtitle_codec.toUpperCase() + ')'; subtitle_decision = 'Transcode (' + s.subtitle_codec.toUpperCase() + ' <i class="fa fa-long-arrow-right"></i> ' + s.stream_subtitle_codec.toUpperCase() + ')';
} else if (s.stream_subtitle_decision === 'copy') { } else if (s.stream_subtitle_decision === 'copy') {
subtitle_decision = 'Direct Stream (' + s.subtitle_codec.toUpperCase() + ')'; subtitle_decision = 'Direct Stream (' + s.subtitle_codec.toUpperCase() + ')';
} else if (s.stream_subtitle_decision === 'burn') { } else if (s.stream_subtitle_decision === 'burn') {
@@ -817,7 +818,7 @@
$.ajax({ $.ajax({
url: 'get_changelog', url: 'get_changelog',
data: { data: {
latest_only: true, since_prev_release: true,
update_shown: true update_shown: true
}, },
cache: false, cache: false,

View File

@@ -2,7 +2,7 @@
PNotify.prototype.options.addclass = "stack-bottomright"; PNotify.prototype.options.addclass = "stack-bottomright";
PNotify.prototype.options.buttons.closer_hover = false; PNotify.prototype.options.buttons.closer_hover = false;
PNotify.prototype.options.desktop = { desktop: true, icon: 'images/logo.png' } PNotify.prototype.options.desktop = { desktop: true, icon: 'images/logo-circle.png' };
PNotify.prototype.options.history = false; PNotify.prototype.options.history = false;
PNotify.prototype.options.shadow = false; PNotify.prototype.options.shadow = false;
PNotify.prototype.options.stack = { dir1: 'up', dir2: 'left', firstpos1: 25, firstpos2: 25 }; PNotify.prototype.options.stack = { dir1: 'up', dir2: 'left', firstpos1: 25, firstpos2: 25 };
@@ -21,7 +21,7 @@ function check_notifications() {
$.getJSON('get_browser_notifications', function (data) { $.getJSON('get_browser_notifications', function (data) {
if (data) { if (data) {
$.each(data, function (i, notification) { $.each(data, function (i, notification) {
if (notification.delay == 0) { if (notification.delay === 0) {
PNotify.prototype.options.hide = false; PNotify.prototype.options.hide = false;
} else { } else {
PNotify.prototype.options.hide = true; PNotify.prototype.options.hide = true;
@@ -34,7 +34,7 @@ function check_notifications() {
setTimeout(function () { setTimeout(function () {
"use strict"; "use strict";
check_notifications(); check_notifications();
}, 3000); }, 5000);
} }
$(document).ready(function () { $(document).ready(function () {

View File

@@ -41,17 +41,15 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm-6 col-sm-offset-3"> <div class="col-sm-6 col-sm-offset-3">
<form action="${http_root}auth/login" method="post"> <form id="login-form">
% if msg: <div id="incorrect-login" class="alert alert-danger" style="text-align: center; padding: 8px; display: none;">
<div class="alert alert-danger" style="text-align: center; padding: 8px;"> Incorrect username or password.
${msg}
</div> </div>
% endif
<div class="form-group"> <div class="form-group">
<label for="username" class="control-label"> <label for="username" class="control-label">
Username Username
</label> </label>
<input type="text" id="username" name="username" class="form-control" autocorrect="off" autocapitalize="off" value="${username}" autofocus> <input type="text" id="username" name="username" class="form-control" autocorrect="off" autocapitalize="off" autofocus>
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password" class="control-label"> <label for="password" class="control-label">
@@ -65,7 +63,7 @@
<input type="checkbox" id="remember_me" name="remember_me" title="for 30 days" value="1" checked="checked" /> Remember me <input type="checkbox" id="remember_me" name="remember_me" title="for 30 days" value="1" checked="checked" /> Remember me
</label> </label>
</div> </div>
<button type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button> <button id="sign-in" type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button>
</div> </div>
</form> </form>
</div> </div>
@@ -75,5 +73,30 @@
</div> </div>
</div> </div>
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
<script>
$('#login-form').submit(function(event) {
event.preventDefault();
$('#sign-in').prop('disabled', true).html('<i class="fa fa-refresh fa-spin"></i>&nbsp; Sign In');
$.ajax({
url: '${http_root}auth/signin',
type: 'POST',
data: $(this).serialize(),
dataType: 'json',
statusCode: {
200: function() {
window.location = "${http_root}";
},
401: function() {
$('#incorrect-login').show();
$('#username').focus();
}
},
complete: function() {
$('#sign-in').prop('disabled', false).html('<i class="fa fa-sign-in"></i>&nbsp; Sign In');
}
});
});
</script>
</body> </body>
</html> </html>

View File

@@ -385,8 +385,9 @@
$("#clear-logs").click(function () { $("#clear-logs").click(function () {
var logfile = $(".tab-pane.active").data('logfile') var logfile = $(".tab-pane.active").data('logfile')
var title = $("#log_tabs li.active a").text()
$("#confirm-message").text("Are you sure you want to clear the Tautulli logs?"); $("#confirm-message").text("Are you sure you want to clear the " + title + "?");
$('#confirm-modal').modal(); $('#confirm-modal').modal();
$('#confirm-modal').one('click', '#confirm-button', function () { $('#confirm-modal').one('click', '#confirm-button', function () {
$.ajax({ $.ajax({
@@ -421,7 +422,7 @@
}); });
$("#clear-notify-logs").click(function () { $("#clear-notify-logs").click(function () {
$("#confirm-message").text("Are you sure you want to clear the Tautulli notification logs?"); $("#confirm-message").text("Are you sure you want to clear the Tautulli Notification Logs?");
$('#confirm-modal').modal(); $('#confirm-modal').modal();
$('#confirm-modal').one('click', '#confirm-button', function () { $('#confirm-modal').one('click', '#confirm-button', function () {
$.ajax({ $.ajax({
@@ -442,7 +443,7 @@
}); });
$("#clear-login-logs").click(function () { $("#clear-login-logs").click(function () {
$("#confirm-message").text("Are you sure you want to clear the Tautulli login logs?"); $("#confirm-message").text("Are you sure you want to clear the Tautulli Login Logs?");
$('#confirm-modal').modal(); $('#confirm-modal').modal();
$('#confirm-modal').one('click', '#confirm-button', function () { $('#confirm-modal').one('click', '#confirm-button', function () {
$.ajax({ $.ajax({

View File

@@ -1,6 +1,10 @@
<%! <%!
from plexpy import helpers, notifiers import json
from plexpy import helpers, notifiers, users
available_notification_actions = notifiers.available_notification_actions() available_notification_actions = notifiers.available_notification_actions()
user_emails = [{'user': u['friendly_name'] or u['username'], 'email': u['email']} for u in users.Users().get_users() if u['email']]
sorted(user_emails, key=lambda u: u['user'])
%> %>
% if notifier: % if notifier:
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" /> <link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
@@ -39,7 +43,7 @@
<div class="form-group"> <div class="form-group">
<label for="${item['name']}">${item['label']}</label> <label for="${item['name']}">${item['label']}</label>
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-12">
<input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}> <input type="${item['input_type']}" class="form-control" id="${item['name']}" name="${item['name']}" value="${item['value']}" size="30" ${'readonly' if item.get('readonly') else ''}>
% if item['name'] == 'osx_notify_app': % if item['name'] == 'osx_notify_app':
<a href="javascript:void(0)" id="osxnotifyregister">Register</a> <a href="javascript:void(0)" id="osxnotifyregister">Register</a>
@@ -62,7 +66,7 @@
<div class="form-group"> <div class="form-group">
<label for="${item['name']}">${item['label']}</label> <label for="${item['name']}">${item['label']}</label>
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-12">
<input type="button" class="btn btn-bright" id="${item['name']}" name="${item['name']}" value="${item['value']}"> <input type="button" class="btn btn-bright" id="${item['name']}" name="${item['name']}" value="${item['value']}">
</div> </div>
</div> </div>
@@ -80,7 +84,7 @@
<div class="form-group"> <div class="form-group">
<label for="${item['name']}">${item['label']}</label> <label for="${item['name']}">${item['label']}</label>
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-12">
<select class="form-control" id="${item['name']}" name="${item['name']}"> <select class="form-control" id="${item['name']}" name="${item['name']}">
% for key, value in sorted(item['select_options'].iteritems()): % for key, value in sorted(item['select_options'].iteritems()):
% if key == item['value']: % if key == item['value']:
@@ -94,6 +98,33 @@
</div> </div>
<p class="help-block">${item['description'] | n}</p> <p class="help-block">${item['description'] | n}</p>
</div> </div>
% elif item['input_type'] == 'selectize':
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="${item['name']}" name="${item['name']}">
<option value="select-all">Select All</option>
<option value="remove-all">Remove All</option>
% if isinstance(item['select_options'], dict):
% for section, options in item['select_options'].iteritems():
<optgroup label="${section}">
% for option in sorted(options, key=lambda x: x['text'].lower()):
<option value="${option['value']}">${option['text']}</option>
% endfor
</optgroup>
% endfor
% else:
<option value="border-all"></option>
% for option in sorted(item['select_options'], key=lambda x: x['text'].lower()):
<option value="${option['value']}">${option['text']}</option>
% endfor
% endif
</select>
</div>
</div>
<p class="help-block">${item['description'] | n}</p>
</div>
% endif % endif
% endfor % endfor
</div> </div>
@@ -101,7 +132,7 @@
<div class="form-group"> <div class="form-group">
<label for="friendly_name">Description</label> <label for="friendly_name">Description</label>
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-12">
<input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${notifier['friendly_name']}" size="30"> <input type="text" class="form-control" id="friendly_name" name="friendly_name" value="${notifier['friendly_name']}" size="30">
</div> </div>
</div> </div>
@@ -132,7 +163,7 @@
<div role="tabpanel" class="tab-pane" id="tabs-notify_conditions"> <div role="tabpanel" class="tab-pane" id="tabs-notify_conditions">
<label>Notification Conditions</label> <label>Notification Conditions</label>
<p class="help-block"> <p class="help-block">
Add custom conditions to filter out notifications. Add custom conditions to only <strong>allow certain notifications</strong>. By default, all notifications will be sent if there are no conditions.
<a href="#notify-text-sub-modal" data-toggle="modal">Click here</a> for a description of all the parameters. <a href="#notify-text-sub-modal" data-toggle="modal">Click here</a> for a description of all the parameters.
</p> </p>
<div id="condition-widget"></div> <div id="condition-widget"></div>
@@ -185,7 +216,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-12">
<input type="button" class="btn btn-bright notifier-text-preview" data-action="${action['name']}" value="Preview Arguments"> <input type="button" class="btn btn-bright notifier-text-preview" data-action="${action['name']}" value="Preview Arguments">
</div> </div>
</div> </div>
@@ -212,7 +243,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-12">
<input type="button" class="btn btn-bright notifier-text-preview" data-action="${action['name']}" value="Preview Text"> <input type="button" class="btn btn-bright notifier-text-preview" data-action="${action['name']}" value="Preview Text">
</div> </div>
</div> </div>
@@ -278,7 +309,7 @@
% endif % endif
<div class="form-group"> <div class="form-group">
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-12">
<input type="button" class="btn btn-bright" id="test_notifier" name="test_notifier" value="Test ${notifier['agent_label']}"> <input type="button" class="btn btn-bright" id="test_notifier" name="test_notifier" value="Test ${notifier['agent_label']}">
</div> </div>
</div> </div>
@@ -302,7 +333,7 @@
$('#notifier-config-modal').unbind('hidden.bs.modal'); $('#notifier-config-modal').unbind('hidden.bs.modal');
// Need this for setting conditions since conditions contain the character " // Need this for setting conditions since conditions contain the character "
$('#custom_conditions').val('${notifier['custom_conditions'] | n}') $('#custom_conditions').val(${json.dumps(notifier["custom_conditions"]) | n});
$('#condition-widget').filterer({ $('#condition-widget').filterer({
parameters: ${parameters | n}, parameters: ${parameters | n},
@@ -310,7 +341,7 @@
updateConditions: function(newConditions){ updateConditions: function(newConditions){
$('#custom_conditions').val(JSON.stringify(newConditions)); $('#custom_conditions').val(JSON.stringify(newConditions));
} }
}) });
function reloadModal() { function reloadModal() {
$.ajax({ $.ajax({
@@ -328,7 +359,7 @@
if (jqXHR) { if (jqXHR) {
var result = $.parseJSON(jqXHR.responseText); var result = $.parseJSON(jqXHR.responseText);
var msg = result.message; var msg = result.message;
if (result.result == 'success') { if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000) showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000)
} else { } else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true) showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true)
@@ -388,7 +419,7 @@
% if notifier['agent_name'] == 'facebook': % if notifier['agent_name'] == 'facebook':
function disableFacebookRequest() { function disableFacebookRequest() {
if ($('#facebook_app_id').val() != '' && $('#facebook_app_secret').val() != '') { $('#facebook_facebookStep1').prop('disabled', false); } if ($('#facebook_app_id').val() !== '' && $('#facebook_app_secret').val() !== '') { $('#facebook_facebookStep1').prop('disabled', false); }
else { $('#facebook_facebookStep1').prop('disabled', true); } else { $('#facebook_facebookStep1').prop('disabled', true); }
} }
disableFacebookRequest(); disableFacebookRequest();
@@ -402,19 +433,20 @@
$('#facebook_redirect_uri').val($('#facebook_redirect_uri').val().slice(0, -1)); $('#facebook_redirect_uri').val($('#facebook_redirect_uri').val().slice(0, -1));
} }
var facebook_token;
$.ajax({ $.ajax({
url: 'facebookStep1', url: 'facebookStep1',
data: { data: {
app_id: $('#facebook_app_id').val(), app_id: $('#facebook_app_id').val(),
app_secret: $('#facebook_app_secret').val(), app_secret: $('#facebook_app_secret').val(),
redirect_uri: $('#facebook_redirect_uri').val(), redirect_uri: $('#facebook_redirect_uri').val()
}, },
cache: false, cache: false,
async: true, async: true,
complete: function (xhr, status) { complete: function (xhr, status) {
var result = $.parseJSON(xhr.responseText); var result = $.parseJSON(xhr.responseText);
var msg = result.msg; var msg = result.msg;
if (result.result == 'success') { if (result.result === 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000); showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
window.open(result.url); window.open(result.url);
@@ -453,18 +485,101 @@
$('#notifier-config-modal').on('hidden.bs.modal', function () { $('#notifier-config-modal').on('hidden.bs.modal', function () {
facebook_token = false; facebook_token = false;
}) });
% elif notifier['agent_name'] == 'browser': % elif notifier['agent_name'] == 'browser':
$('#browser_allow_browser').click(function () { $('#browser_allow_browser').click(function () {
PNotify.desktop.permission(); PNotify.desktop.permission();
}) });
% elif notifier['agent_name'] == 'osx': % elif notifier['agent_name'] == 'osx':
$('#osxnotifyregister').click(function () { $('#osxnotifyregister').click(function () {
var osx_notify_app = $('#osx_notify_app').val(); var osx_notify_app = $('#osx_notify_app').val();
$.get('osxnotifyregister', { 'app': osx_notify_app }, function (data) { showMsg('<i class="fa fa-check"></i> ' + data, false, true, 3000); }); $.get('osxnotifyregister', { 'app': osx_notify_app }, function (data) { showMsg('<i class="fa fa-check"></i> ' + data, false, true, 3000); });
}) });
% elif notifier['agent_name'] == 'email':
var REGEX_EMAIL = '([a-z0-9!#$%&\'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&\'*+/=?^_`{|}~-]+)*@' +
'(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)';
var $email_selectors = $('#email_to, #email_cc, #email_bcc').selectize({
plugins: ['remove_button'],
persist: false,
maxItems: null,
render: {
item: function(item, escape) {
return '<div>' +
(item.text ? '<span class="item-text">' + escape(item.text) + '</span>' : '') +
(item.value ? '<span class="item-value">' + escape(item.value) + '</span>' : '') +
'</div>';
},
option: function(item, escape) {
var label = item.text || item.value;
var caption = item.text ? item.value : null;
if (item.value.endsWith('-all')) {
return '<div class="' + item.value + '">' + escape(label) + '</div>'
}
return '<div>' +
escape(label) +
(caption ? '<span class="caption">' + escape(caption) + '</span>' : '') +
'</div>';
}
},
onItemAdd: function(value) {
if (value === 'select-all') {
var all_keys = $.map(this.options, function(option){
return option.value.endsWith('-all') ? null : option.value;
});
this.setValue(all_keys);
} else if (value === 'remove-all') {
this.clear();
this.refreshOptions();
this.positionDropdown();
}
},
createFilter: function(input) {
var match, regex;
// email@address.com
regex = new RegExp('^' + REGEX_EMAIL + '$', 'i');
match = input.match(regex);
if (match) return !this.options.hasOwnProperty(match[0]);
// user <email@address.com>
regex = new RegExp('^([^<]*)\<' + REGEX_EMAIL + '\>$', 'i');
match = input.match(regex);
if (match) return !this.options.hasOwnProperty(match[2]);
return false;
},
create: function(input) {
if ((new RegExp('^' + REGEX_EMAIL + '$', 'i')).test(input)) {
return {value: input};
}
var match = input.match(new RegExp('^([^<]*)\<' + REGEX_EMAIL + '\>$', 'i'));
if (match) {
return {
value : match[2],
text : $.trim(match[1])
};
}
return false;
}
});
var email_to = $email_selectors[0].selectize;
var email_cc = $email_selectors[1].selectize;
var email_bcc = $email_selectors[2].selectize;
email_to.setValue(${json.dumps(next((c['value'] for c in notifier['config_options'] if c['name'] == 'email_to'), [])) | n});
email_cc.setValue(${json.dumps(next((c['value'] for c in notifier['config_options'] if c['name'] == 'email_cc'), [])) | n});
email_bcc.setValue(${json.dumps(next((c['value'] for c in notifier['config_options'] if c['name'] == 'email_bcc'), [])) | n});
% elif notifier['agent_name'] == 'join':
var $join_device_names = $('#join_device_names').selectize({
plugins: ['remove_button'],
maxItems: null,
create: true
});
var join_device_names = $join_device_names[0].selectize;
join_device_names.setValue(${json.dumps(next((c['value'] for c in notifier['config_options'] if c['name'] == 'join_device_names'), [])) | n});
% endif % endif
function validateLogic() { function validateLogic() {
@@ -595,7 +710,7 @@
}); });
function sendTestNotification() { function sendTestNotification() {
if ('${notifier["agent_name"]}' != 'browser') { if ('${notifier["agent_name"]}' !== 'browser') {
$.ajax({ $.ajax({
url: 'send_notification', url: 'send_notification',
data: { data: {
@@ -619,7 +734,7 @@
} }
}); });
} else { } else {
if ($('#browser_auto_hide_delay').val() == "0") { if ($('#browser_auto_hide_delay').val() === "0") {
PNotify.prototype.options.hide = false; PNotify.prototype.options.hide = false;
} else { } else {
PNotify.prototype.options.hide = true; PNotify.prototype.options.hide = true;

View File

@@ -13,6 +13,8 @@
</%def> </%def>
<%def name="headerIncludes()"> <%def name="headerIncludes()">
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
<link href="${http_root}css/selectize.min.css" rel="stylesheet" />
</%def> </%def>
<%def name="body()"> <%def name="body()">
@@ -470,6 +472,13 @@
</div> </div>
<input type="text" id="http_hashed_password" name="http_hashed_password" value="${config['http_hashed_password']}" style="display: none;" data-parsley-trigger="change" data-parsley-type="integer" data-parsley-range="[0, 1]" <input type="text" id="http_hashed_password" name="http_hashed_password" value="${config['http_hashed_password']}" style="display: none;" data-parsley-trigger="change" data-parsley-type="integer" data-parsley-range="[0, 1]"
data-parsley-errors-container="#http_hash_password_error" data-parsley-error-message="Cannot un-hash password, please set a new password." data-parsley-no-focus required> data-parsley-errors-container="#http_hash_password_error" data-parsley-error-message="Cannot un-hash password, please set a new password." data-parsley-no-focus required>
<div class="checkbox">
<label>
<input type="checkbox" class="auth-settings" name="http_plex_admin" id="http_plex_admin" value="1" ${config['http_plex_admin']} data-parsley-trigger="change"> Allow Plex Admin
</label>
<span id="allowPlexCheck" style="color: #eb8600; padding-left: 10px;"></span>
<p class="help-block">Allow the Plex server admin to login as a Tautulli admin using their Plex.tv account.</p>
</div>
<div class="checkbox"> <div class="checkbox">
<label> <label>
<input type="checkbox" class="auth-settings" name="http_basic_auth" id="http_basic_auth" value="1" ${config['http_basic_auth']} data-parsley-trigger="change"> Use Basic Authentication <input type="checkbox" class="auth-settings" name="http_basic_auth" id="http_basic_auth" value="1" ${config['http_basic_auth']} data-parsley-trigger="change"> Use Basic Authentication
@@ -477,6 +486,7 @@
<p class="help-block">Use basic HTTP authentication instead of the HTML login form.</p> <p class="help-block">Use basic HTTP authentication instead of the HTML login form.</p>
</div> </div>
<input type="checkbox" name="auth_changed" id="auth_changed" value="1" style="display: none;">
<div class="padded-header"> <div class="padded-header">
<h3>Guest Access</h3> <h3>Guest Access</h3>
@@ -1500,6 +1510,7 @@
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
<script src="${http_root}js/parsley.min.js"></script> <script src="${http_root}js/parsley.min.js"></script>
<script src="${http_root}js/Sortable.min.js"></script> <script src="${http_root}js/Sortable.min.js"></script>
<script src="${http_root}js/selectize.min.js"></script>
<script src="${http_root}js/moment-with-locale.js"></script> <script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/jquery.qrcode.min.js"></script> <script src="${http_root}js/jquery.qrcode.min.js"></script>
<script> <script>
@@ -1774,6 +1785,7 @@ $(document).ready(function() {
$( ".auth-settings" ).change(function() { $( ".auth-settings" ).change(function() {
authChanged = true; authChanged = true;
$("#auth_changed").prop('checked', true);
}); });
$( ".directory-settings" ).change(function() { $( ".directory-settings" ).change(function() {
@@ -2013,6 +2025,26 @@ $(document).ready(function() {
} }
}); });
function allowPlexAdminCheck () {
if ($("#http_basic_auth").is(":checked")) {
$("#http_plex_admin").attr("disabled", true);
$("#http_plex_admin").attr("checked", false);
$("#allowPlexCheck").html("Plex admin login cannot be enabled with basic authentication.");
} else if ($('#http_username').val() == '' || $('#http_password').val() == '') {
$("#http_plex_admin").attr("disabled", true);
$("#http_plex_admin").attr("checked", false);
$("#allowPlexCheck").html("You must set an admin username and password above to allow Plex admin login.");
} else {
$("#http_plex_admin").attr("disabled", false);
$("#allowPlexCheck").html("");
}
}
allowPlexAdminCheck();
$('#http_username, #http_password, #http_basic_auth').change(function () {
allowPlexAdminCheck();
});
function allowGuestAccessCheck () { function allowGuestAccessCheck () {
if ($("#http_basic_auth").is(":checked")) { if ($("#http_basic_auth").is(":checked")) {
$("#allow_guest_access").attr("disabled", true); $("#allow_guest_access").attr("disabled", true);
@@ -2021,7 +2053,7 @@ $(document).ready(function() {
} else if ($('#http_username').val() == '' || $('#http_password').val() == '') { } else if ($('#http_username').val() == '' || $('#http_password').val() == '') {
$("#allow_guest_access").attr("disabled", true); $("#allow_guest_access").attr("disabled", true);
$("#allow_guest_access").attr("checked", false); $("#allow_guest_access").attr("checked", false);
$("#allowGuestCheck").html("You must set an admin password above to allow guest access."); $("#allowGuestCheck").html("You must set an admin username and password above to allow guest access.");
} else { } else {
$("#allow_guest_access").attr("disabled", false); $("#allow_guest_access").attr("disabled", false);
$("#allowGuestCheck").html(""); $("#allowGuestCheck").html("");

29
lib/jwt/__init__.py Normal file
View File

@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# flake8: noqa
"""
JSON Web Token implementation
Minimum implementation based on this spec:
http://self-issued.info/docs/draft-jones-json-web-token-01.html
"""
__title__ = 'pyjwt'
__version__ = '1.4.0'
__author__ = 'José Padilla'
__license__ = 'MIT'
__copyright__ = 'Copyright 2015 José Padilla'
from .api_jwt import (
encode, decode, register_algorithm, unregister_algorithm,
get_unverified_header, PyJWT
)
from .api_jws import PyJWS
from .exceptions import (
InvalidTokenError, DecodeError, InvalidAudienceError,
ExpiredSignatureError, ImmatureSignatureError, InvalidIssuedAtError,
InvalidIssuerError, ExpiredSignature, InvalidAudience, InvalidIssuer,
MissingRequiredClaimError
)

135
lib/jwt/__main__.py Normal file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python
from __future__ import absolute_import, print_function
import json
import optparse
import sys
import time
from . import DecodeError, __package__, __version__, decode, encode
def main():
usage = '''Encodes or decodes JSON Web Tokens based on input.
%prog [options] input
Decoding examples:
%prog --key=secret json.web.token
%prog --no-verify json.web.token
Encoding requires the key option and takes space separated key/value pairs
separated by equals (=) as input. Examples:
%prog --key=secret iss=me exp=1302049071
%prog --key=secret foo=bar exp=+10
The exp key is special and can take an offset to current Unix time.\
'''
p = optparse.OptionParser(
usage=usage,
prog=__package__,
version='%s %s' % (__package__, __version__),
)
p.add_option(
'-n', '--no-verify',
action='store_false',
dest='verify',
default=True,
help='ignore signature verification on decode'
)
p.add_option(
'--key',
dest='key',
metavar='KEY',
default=None,
help='set the secret key to sign with'
)
p.add_option(
'--alg',
dest='algorithm',
metavar='ALG',
default='HS256',
help='set crypto algorithm to sign with. default=HS256'
)
options, arguments = p.parse_args()
if len(arguments) > 0 or not sys.stdin.isatty():
if len(arguments) == 1 and (not options.verify or options.key):
# Try to decode
try:
if not sys.stdin.isatty():
token = sys.stdin.read()
else:
token = arguments[0]
token = token.encode('utf-8')
data = decode(token, key=options.key, verify=options.verify)
print(json.dumps(data))
sys.exit(0)
except DecodeError as e:
print(e)
sys.exit(1)
# Try to encode
if options.key is None:
print('Key is required when encoding. See --help for usage.')
sys.exit(1)
# Build payload object to encode
payload = {}
for arg in arguments:
try:
k, v = arg.split('=', 1)
# exp +offset special case?
if k == 'exp' and v[0] == '+' and len(v) > 1:
v = str(int(time.time()+int(v[1:])))
# Cast to integer?
if v.isdigit():
v = int(v)
else:
# Cast to float?
try:
v = float(v)
except ValueError:
pass
# Cast to true, false, or null?
constants = {'true': True, 'false': False, 'null': None}
if v in constants:
v = constants[v]
payload[k] = v
except ValueError:
print('Invalid encoding input at {}'.format(arg))
sys.exit(1)
try:
token = encode(
payload,
key=options.key,
algorithm=options.algorithm
)
print(token)
sys.exit(0)
except Exception as e:
print(e)
sys.exit(1)
else:
p.print_help()
if __name__ == '__main__':
main()

290
lib/jwt/algorithms.py Normal file
View File

@@ -0,0 +1,290 @@
import hashlib
import hmac
from .compat import constant_time_compare, string_types, text_type
from .exceptions import InvalidKeyError
from .utils import der_to_raw_signature, raw_to_der_signature
try:
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.serialization import (
load_pem_private_key, load_pem_public_key, load_ssh_public_key
)
from cryptography.hazmat.primitives.asymmetric.rsa import (
RSAPrivateKey, RSAPublicKey
)
from cryptography.hazmat.primitives.asymmetric.ec import (
EllipticCurvePrivateKey, EllipticCurvePublicKey
)
from cryptography.hazmat.primitives.asymmetric import ec, padding
from cryptography.hazmat.backends import default_backend
from cryptography.exceptions import InvalidSignature
has_crypto = True
except ImportError:
has_crypto = False
def get_default_algorithms():
"""
Returns the algorithms that are implemented by the library.
"""
default_algorithms = {
'none': NoneAlgorithm(),
'HS256': HMACAlgorithm(HMACAlgorithm.SHA256),
'HS384': HMACAlgorithm(HMACAlgorithm.SHA384),
'HS512': HMACAlgorithm(HMACAlgorithm.SHA512)
}
if has_crypto:
default_algorithms.update({
'RS256': RSAAlgorithm(RSAAlgorithm.SHA256),
'RS384': RSAAlgorithm(RSAAlgorithm.SHA384),
'RS512': RSAAlgorithm(RSAAlgorithm.SHA512),
'ES256': ECAlgorithm(ECAlgorithm.SHA256),
'ES384': ECAlgorithm(ECAlgorithm.SHA384),
'ES512': ECAlgorithm(ECAlgorithm.SHA512),
'PS256': RSAPSSAlgorithm(RSAPSSAlgorithm.SHA256),
'PS384': RSAPSSAlgorithm(RSAPSSAlgorithm.SHA384),
'PS512': RSAPSSAlgorithm(RSAPSSAlgorithm.SHA512)
})
return default_algorithms
class Algorithm(object):
"""
The interface for an algorithm used to sign and verify tokens.
"""
def prepare_key(self, key):
"""
Performs necessary validation and conversions on the key and returns
the key value in the proper format for sign() and verify().
"""
raise NotImplementedError
def sign(self, msg, key):
"""
Returns a digital signature for the specified message
using the specified key value.
"""
raise NotImplementedError
def verify(self, msg, key, sig):
"""
Verifies that the specified digital signature is valid
for the specified message and key values.
"""
raise NotImplementedError
class NoneAlgorithm(Algorithm):
"""
Placeholder for use when no signing or verification
operations are required.
"""
def prepare_key(self, key):
if key == '':
key = None
if key is not None:
raise InvalidKeyError('When alg = "none", key value must be None.')
return key
def sign(self, msg, key):
return b''
def verify(self, msg, key, sig):
return False
class HMACAlgorithm(Algorithm):
"""
Performs signing and verification operations using HMAC
and the specified hash function.
"""
SHA256 = hashlib.sha256
SHA384 = hashlib.sha384
SHA512 = hashlib.sha512
def __init__(self, hash_alg):
self.hash_alg = hash_alg
def prepare_key(self, key):
if not isinstance(key, string_types) and not isinstance(key, bytes):
raise TypeError('Expecting a string- or bytes-formatted key.')
if isinstance(key, text_type):
key = key.encode('utf-8')
invalid_strings = [
b'-----BEGIN PUBLIC KEY-----',
b'-----BEGIN CERTIFICATE-----',
b'ssh-rsa'
]
if any([string_value in key for string_value in invalid_strings]):
raise InvalidKeyError(
'The specified key is an asymmetric key or x509 certificate and'
' should not be used as an HMAC secret.')
return key
def sign(self, msg, key):
return hmac.new(key, msg, self.hash_alg).digest()
def verify(self, msg, key, sig):
return constant_time_compare(sig, self.sign(msg, key))
if has_crypto:
class RSAAlgorithm(Algorithm):
"""
Performs signing and verification operations using
RSASSA-PKCS-v1_5 and the specified hash function.
"""
SHA256 = hashes.SHA256
SHA384 = hashes.SHA384
SHA512 = hashes.SHA512
def __init__(self, hash_alg):
self.hash_alg = hash_alg
def prepare_key(self, key):
if isinstance(key, RSAPrivateKey) or \
isinstance(key, RSAPublicKey):
return key
if isinstance(key, string_types):
if isinstance(key, text_type):
key = key.encode('utf-8')
try:
if key.startswith(b'ssh-rsa'):
key = load_ssh_public_key(key, backend=default_backend())
else:
key = load_pem_private_key(key, password=None, backend=default_backend())
except ValueError:
key = load_pem_public_key(key, backend=default_backend())
else:
raise TypeError('Expecting a PEM-formatted key.')
return key
def sign(self, msg, key):
signer = key.signer(
padding.PKCS1v15(),
self.hash_alg()
)
signer.update(msg)
return signer.finalize()
def verify(self, msg, key, sig):
verifier = key.verifier(
sig,
padding.PKCS1v15(),
self.hash_alg()
)
verifier.update(msg)
try:
verifier.verify()
return True
except InvalidSignature:
return False
class ECAlgorithm(Algorithm):
"""
Performs signing and verification operations using
ECDSA and the specified hash function
"""
SHA256 = hashes.SHA256
SHA384 = hashes.SHA384
SHA512 = hashes.SHA512
def __init__(self, hash_alg):
self.hash_alg = hash_alg
def prepare_key(self, key):
if isinstance(key, EllipticCurvePrivateKey) or \
isinstance(key, EllipticCurvePublicKey):
return key
if isinstance(key, string_types):
if isinstance(key, text_type):
key = key.encode('utf-8')
# Attempt to load key. We don't know if it's
# a Signing Key or a Verifying Key, so we try
# the Verifying Key first.
try:
key = load_pem_public_key(key, backend=default_backend())
except ValueError:
key = load_pem_private_key(key, password=None, backend=default_backend())
else:
raise TypeError('Expecting a PEM-formatted key.')
return key
def sign(self, msg, key):
signer = key.signer(ec.ECDSA(self.hash_alg()))
signer.update(msg)
der_sig = signer.finalize()
return der_to_raw_signature(der_sig, key.curve)
def verify(self, msg, key, sig):
try:
der_sig = raw_to_der_signature(sig, key.curve)
except ValueError:
return False
verifier = key.verifier(der_sig, ec.ECDSA(self.hash_alg()))
verifier.update(msg)
try:
verifier.verify()
return True
except InvalidSignature:
return False
class RSAPSSAlgorithm(RSAAlgorithm):
"""
Performs a signature using RSASSA-PSS with MGF1
"""
def sign(self, msg, key):
signer = key.signer(
padding.PSS(
mgf=padding.MGF1(self.hash_alg()),
salt_length=self.hash_alg.digest_size
),
self.hash_alg()
)
signer.update(msg)
return signer.finalize()
def verify(self, msg, key, sig):
verifier = key.verifier(
sig,
padding.PSS(
mgf=padding.MGF1(self.hash_alg()),
salt_length=self.hash_alg.digest_size
),
self.hash_alg()
)
verifier.update(msg)
try:
verifier.verify()
return True
except InvalidSignature:
return False

189
lib/jwt/api_jws.py Normal file
View File

@@ -0,0 +1,189 @@
import binascii
import json
import warnings
from collections import Mapping
from .algorithms import Algorithm, get_default_algorithms # NOQA
from .compat import text_type
from .exceptions import DecodeError, InvalidAlgorithmError
from .utils import base64url_decode, base64url_encode, merge_dict
class PyJWS(object):
header_typ = 'JWT'
def __init__(self, algorithms=None, options=None):
self._algorithms = get_default_algorithms()
self._valid_algs = (set(algorithms) if algorithms is not None
else set(self._algorithms))
# Remove algorithms that aren't on the whitelist
for key in list(self._algorithms.keys()):
if key not in self._valid_algs:
del self._algorithms[key]
if not options:
options = {}
self.options = merge_dict(self._get_default_options(), options)
@staticmethod
def _get_default_options():
return {
'verify_signature': True
}
def register_algorithm(self, alg_id, alg_obj):
"""
Registers a new Algorithm for use when creating and verifying tokens.
"""
if alg_id in self._algorithms:
raise ValueError('Algorithm already has a handler.')
if not isinstance(alg_obj, Algorithm):
raise TypeError('Object is not of type `Algorithm`')
self._algorithms[alg_id] = alg_obj
self._valid_algs.add(alg_id)
def unregister_algorithm(self, alg_id):
"""
Unregisters an Algorithm for use when creating and verifying tokens
Throws KeyError if algorithm is not registered.
"""
if alg_id not in self._algorithms:
raise KeyError('The specified algorithm could not be removed'
' because it is not registered.')
del self._algorithms[alg_id]
self._valid_algs.remove(alg_id)
def get_algorithms(self):
"""
Returns a list of supported values for the 'alg' parameter.
"""
return list(self._valid_algs)
def encode(self, payload, key, algorithm='HS256', headers=None,
json_encoder=None):
segments = []
if algorithm is None:
algorithm = 'none'
if algorithm not in self._valid_algs:
pass
# Header
header = {'typ': self.header_typ, 'alg': algorithm}
if headers:
header.update(headers)
json_header = json.dumps(
header,
separators=(',', ':'),
cls=json_encoder
).encode('utf-8')
segments.append(base64url_encode(json_header))
segments.append(base64url_encode(payload))
# Segments
signing_input = b'.'.join(segments)
try:
alg_obj = self._algorithms[algorithm]
key = alg_obj.prepare_key(key)
signature = alg_obj.sign(signing_input, key)
except KeyError:
raise NotImplementedError('Algorithm not supported')
segments.append(base64url_encode(signature))
return b'.'.join(segments)
def decode(self, jws, key='', verify=True, algorithms=None, options=None,
**kwargs):
payload, signing_input, header, signature = self._load(jws)
if verify:
merged_options = merge_dict(self.options, options)
if merged_options.get('verify_signature'):
self._verify_signature(payload, signing_input, header, signature,
key, algorithms)
else:
warnings.warn('The verify parameter is deprecated. '
'Please use options instead.', DeprecationWarning)
return payload
def get_unverified_header(self, jwt):
"""Returns back the JWT header parameters as a dict()
Note: The signature is not verified so the header parameters
should not be fully trusted until signature verification is complete
"""
return self._load(jwt)[2]
def _load(self, jwt):
if isinstance(jwt, text_type):
jwt = jwt.encode('utf-8')
try:
signing_input, crypto_segment = jwt.rsplit(b'.', 1)
header_segment, payload_segment = signing_input.split(b'.', 1)
except ValueError:
raise DecodeError('Not enough segments')
try:
header_data = base64url_decode(header_segment)
except (TypeError, binascii.Error):
raise DecodeError('Invalid header padding')
try:
header = json.loads(header_data.decode('utf-8'))
except ValueError as e:
raise DecodeError('Invalid header string: %s' % e)
if not isinstance(header, Mapping):
raise DecodeError('Invalid header string: must be a json object')
try:
payload = base64url_decode(payload_segment)
except (TypeError, binascii.Error):
raise DecodeError('Invalid payload padding')
try:
signature = base64url_decode(crypto_segment)
except (TypeError, binascii.Error):
raise DecodeError('Invalid crypto padding')
return (payload, signing_input, header, signature)
def _verify_signature(self, payload, signing_input, header, signature,
key='', algorithms=None):
alg = header.get('alg')
if algorithms is not None and alg not in algorithms:
raise InvalidAlgorithmError('The specified alg value is not allowed')
try:
alg_obj = self._algorithms[alg]
key = alg_obj.prepare_key(key)
if not alg_obj.verify(signing_input, key, signature):
raise DecodeError('Signature verification failed')
except KeyError:
raise InvalidAlgorithmError('Algorithm not supported')
_jws_global_obj = PyJWS()
encode = _jws_global_obj.encode
decode = _jws_global_obj.decode
register_algorithm = _jws_global_obj.register_algorithm
unregister_algorithm = _jws_global_obj.unregister_algorithm
get_unverified_header = _jws_global_obj.get_unverified_header

187
lib/jwt/api_jwt.py Normal file
View File

@@ -0,0 +1,187 @@
import json
import warnings
from calendar import timegm
from collections import Mapping
from datetime import datetime, timedelta
from .api_jws import PyJWS
from .algorithms import Algorithm, get_default_algorithms # NOQA
from .compat import string_types, timedelta_total_seconds
from .exceptions import (
DecodeError, ExpiredSignatureError, ImmatureSignatureError,
InvalidAudienceError, InvalidIssuedAtError,
InvalidIssuerError, MissingRequiredClaimError
)
from .utils import merge_dict
class PyJWT(PyJWS):
header_type = 'JWT'
@staticmethod
def _get_default_options():
return {
'verify_signature': True,
'verify_exp': True,
'verify_nbf': True,
'verify_iat': True,
'verify_aud': True,
'verify_iss': True,
'require_exp': False,
'require_iat': False,
'require_nbf': False
}
def encode(self, payload, key, algorithm='HS256', headers=None,
json_encoder=None):
# Check that we get a mapping
if not isinstance(payload, Mapping):
raise TypeError('Expecting a mapping object, as JWT only supports '
'JSON objects as payloads.')
# Payload
for time_claim in ['exp', 'iat', 'nbf']:
# Convert datetime to a intDate value in known time-format claims
if isinstance(payload.get(time_claim), datetime):
payload[time_claim] = timegm(payload[time_claim].utctimetuple())
json_payload = json.dumps(
payload,
separators=(',', ':'),
cls=json_encoder
).encode('utf-8')
return super(PyJWT, self).encode(
json_payload, key, algorithm, headers, json_encoder
)
def decode(self, jwt, key='', verify=True, algorithms=None, options=None,
**kwargs):
payload, signing_input, header, signature = self._load(jwt)
decoded = super(PyJWT, self).decode(jwt, key, verify, algorithms,
options, **kwargs)
try:
payload = json.loads(decoded.decode('utf-8'))
except ValueError as e:
raise DecodeError('Invalid payload string: %s' % e)
if not isinstance(payload, Mapping):
raise DecodeError('Invalid payload string: must be a json object')
if verify:
merged_options = merge_dict(self.options, options)
self._validate_claims(payload, merged_options, **kwargs)
return payload
def _validate_claims(self, payload, options, audience=None, issuer=None,
leeway=0, **kwargs):
if 'verify_expiration' in kwargs:
options['verify_exp'] = kwargs.get('verify_expiration', True)
warnings.warn('The verify_expiration parameter is deprecated. '
'Please use options instead.', DeprecationWarning)
if isinstance(leeway, timedelta):
leeway = timedelta_total_seconds(leeway)
if not isinstance(audience, (string_types, type(None))):
raise TypeError('audience must be a string or None')
self._validate_required_claims(payload, options)
now = timegm(datetime.utcnow().utctimetuple())
if 'iat' in payload and options.get('verify_iat'):
self._validate_iat(payload, now, leeway)
if 'nbf' in payload and options.get('verify_nbf'):
self._validate_nbf(payload, now, leeway)
if 'exp' in payload and options.get('verify_exp'):
self._validate_exp(payload, now, leeway)
if options.get('verify_iss'):
self._validate_iss(payload, issuer)
if options.get('verify_aud'):
self._validate_aud(payload, audience)
def _validate_required_claims(self, payload, options):
if options.get('require_exp') and payload.get('exp') is None:
raise MissingRequiredClaimError('exp')
if options.get('require_iat') and payload.get('iat') is None:
raise MissingRequiredClaimError('iat')
if options.get('require_nbf') and payload.get('nbf') is None:
raise MissingRequiredClaimError('nbf')
def _validate_iat(self, payload, now, leeway):
try:
iat = int(payload['iat'])
except ValueError:
raise DecodeError('Issued At claim (iat) must be an integer.')
if iat > (now + leeway):
raise InvalidIssuedAtError('Issued At claim (iat) cannot be in'
' the future.')
def _validate_nbf(self, payload, now, leeway):
try:
nbf = int(payload['nbf'])
except ValueError:
raise DecodeError('Not Before claim (nbf) must be an integer.')
if nbf > (now + leeway):
raise ImmatureSignatureError('The token is not yet valid (nbf)')
def _validate_exp(self, payload, now, leeway):
try:
exp = int(payload['exp'])
except ValueError:
raise DecodeError('Expiration Time claim (exp) must be an'
' integer.')
if exp < (now - leeway):
raise ExpiredSignatureError('Signature has expired')
def _validate_aud(self, payload, audience):
if audience is None and 'aud' not in payload:
return
if audience is not None and 'aud' not in payload:
# Application specified an audience, but it could not be
# verified since the token does not contain a claim.
raise MissingRequiredClaimError('aud')
audience_claims = payload['aud']
if isinstance(audience_claims, string_types):
audience_claims = [audience_claims]
if not isinstance(audience_claims, list):
raise InvalidAudienceError('Invalid claim format in token')
if any(not isinstance(c, string_types) for c in audience_claims):
raise InvalidAudienceError('Invalid claim format in token')
if audience not in audience_claims:
raise InvalidAudienceError('Invalid audience')
def _validate_iss(self, payload, issuer):
if issuer is None:
return
if 'iss' not in payload:
raise MissingRequiredClaimError('iss')
if payload['iss'] != issuer:
raise InvalidIssuerError('Invalid issuer')
_jwt_global_obj = PyJWT()
encode = _jwt_global_obj.encode
decode = _jwt_global_obj.decode
register_algorithm = _jwt_global_obj.register_algorithm
unregister_algorithm = _jwt_global_obj.unregister_algorithm
get_unverified_header = _jwt_global_obj.get_unverified_header

52
lib/jwt/compat.py Normal file
View File

@@ -0,0 +1,52 @@
"""
The `compat` module provides support for backwards compatibility with older
versions of python, and compatibility wrappers around optional packages.
"""
# flake8: noqa
import sys
import hmac
PY3 = sys.version_info[0] == 3
if PY3:
string_types = str,
text_type = str
else:
string_types = basestring,
text_type = unicode
def timedelta_total_seconds(delta):
try:
delta.total_seconds
except AttributeError:
# On Python 2.6, timedelta instances do not have
# a .total_seconds() method.
total_seconds = delta.days * 24 * 60 * 60 + delta.seconds
else:
total_seconds = delta.total_seconds()
return total_seconds
try:
constant_time_compare = hmac.compare_digest
except AttributeError:
# Fallback for Python < 2.7
def constant_time_compare(val1, val2):
"""
Returns True if the two strings are equal, False otherwise.
The time taken is independent of the number of characters that match.
"""
if len(val1) != len(val2):
return False
result = 0
for x, y in zip(val1, val2):
result |= ord(x) ^ ord(y)
return result == 0

View File

View File

View File

@@ -0,0 +1,60 @@
# Note: This file is named py_ecdsa.py because import behavior in Python 2
# would cause ecdsa.py to squash the ecdsa library that it depends upon.
import hashlib
import ecdsa
from jwt.algorithms import Algorithm
from jwt.compat import string_types, text_type
class ECAlgorithm(Algorithm):
"""
Performs signing and verification operations using
ECDSA and the specified hash function
This class requires the ecdsa package to be installed.
This is based off of the implementation in PyJWT 0.3.2
"""
SHA256 = hashlib.sha256
SHA384 = hashlib.sha384
SHA512 = hashlib.sha512
def __init__(self, hash_alg):
self.hash_alg = hash_alg
def prepare_key(self, key):
if isinstance(key, ecdsa.SigningKey) or \
isinstance(key, ecdsa.VerifyingKey):
return key
if isinstance(key, string_types):
if isinstance(key, text_type):
key = key.encode('utf-8')
# Attempt to load key. We don't know if it's
# a Signing Key or a Verifying Key, so we try
# the Verifying Key first.
try:
key = ecdsa.VerifyingKey.from_pem(key)
except ecdsa.der.UnexpectedDER:
key = ecdsa.SigningKey.from_pem(key)
else:
raise TypeError('Expecting a PEM-formatted key.')
return key
def sign(self, msg, key):
return key.sign(msg, hashfunc=self.hash_alg,
sigencode=ecdsa.util.sigencode_string)
def verify(self, msg, key, sig):
try:
return key.verify(sig, msg, hashfunc=self.hash_alg,
sigdecode=ecdsa.util.sigdecode_string)
except AssertionError:
return False

View File

@@ -0,0 +1,47 @@
import Crypto.Hash.SHA256
import Crypto.Hash.SHA384
import Crypto.Hash.SHA512
from Crypto.PublicKey import RSA
from Crypto.Signature import PKCS1_v1_5
from jwt.algorithms import Algorithm
from jwt.compat import string_types, text_type
class RSAAlgorithm(Algorithm):
"""
Performs signing and verification operations using
RSASSA-PKCS-v1_5 and the specified hash function.
This class requires PyCrypto package to be installed.
This is based off of the implementation in PyJWT 0.3.2
"""
SHA256 = Crypto.Hash.SHA256
SHA384 = Crypto.Hash.SHA384
SHA512 = Crypto.Hash.SHA512
def __init__(self, hash_alg):
self.hash_alg = hash_alg
def prepare_key(self, key):
if isinstance(key, RSA._RSAobj):
return key
if isinstance(key, string_types):
if isinstance(key, text_type):
key = key.encode('utf-8')
key = RSA.importKey(key)
else:
raise TypeError('Expecting a PEM- or RSA-formatted key.')
return key
def sign(self, msg, key):
return PKCS1_v1_5.new(key).sign(self.hash_alg.new(msg))
def verify(self, msg, key, sig):
return PKCS1_v1_5.new(key).verify(self.hash_alg.new(msg), sig)

48
lib/jwt/exceptions.py Normal file
View File

@@ -0,0 +1,48 @@
class InvalidTokenError(Exception):
pass
class DecodeError(InvalidTokenError):
pass
class ExpiredSignatureError(InvalidTokenError):
pass
class InvalidAudienceError(InvalidTokenError):
pass
class InvalidIssuerError(InvalidTokenError):
pass
class InvalidIssuedAtError(InvalidTokenError):
pass
class ImmatureSignatureError(InvalidTokenError):
pass
class InvalidKeyError(Exception):
pass
class InvalidAlgorithmError(InvalidTokenError):
pass
class MissingRequiredClaimError(InvalidTokenError):
def __init__(self, claim):
self.claim = claim
def __str__(self):
return 'Token is missing the "%s" claim' % self.claim
# Compatibility aliases (deprecated)
ExpiredSignature = ExpiredSignatureError
InvalidAudience = InvalidAudienceError
InvalidIssuer = InvalidIssuerError

67
lib/jwt/utils.py Normal file
View File

@@ -0,0 +1,67 @@
import base64
import binascii
try:
from cryptography.hazmat.primitives.asymmetric.utils import (
decode_rfc6979_signature, encode_rfc6979_signature
)
except ImportError:
pass
def base64url_decode(input):
rem = len(input) % 4
if rem > 0:
input += b'=' * (4 - rem)
return base64.urlsafe_b64decode(input)
def base64url_encode(input):
return base64.urlsafe_b64encode(input).replace(b'=', b'')
def merge_dict(original, updates):
if not updates:
return original
try:
merged_options = original.copy()
merged_options.update(updates)
except (AttributeError, ValueError) as e:
raise TypeError('original and updates must be a dictionary: %s' % e)
return merged_options
def number_to_bytes(num, num_bytes):
padded_hex = '%0*x' % (2 * num_bytes, num)
big_endian = binascii.a2b_hex(padded_hex.encode('ascii'))
return big_endian
def bytes_to_number(string):
return int(binascii.b2a_hex(string), 16)
def der_to_raw_signature(der_sig, curve):
num_bits = curve.key_size
num_bytes = (num_bits + 7) // 8
r, s = decode_rfc6979_signature(der_sig)
return number_to_bytes(r, num_bytes) + number_to_bytes(s, num_bytes)
def raw_to_der_signature(raw_sig, curve):
num_bits = curve.key_size
num_bytes = (num_bits + 7) // 8
if len(raw_sig) != 2 * num_bytes:
raise ValueError('Invalid signature')
r = bytes_to_number(raw_sig[:num_bytes])
s = bytes_to_number(raw_sig[num_bytes:])
return encode_rfc6979_signature(r, s)

View File

@@ -34,7 +34,7 @@ from apscheduler.triggers.interval import IntervalTrigger
import activity_handler import activity_handler
import activity_pinger import activity_pinger
import config import common
import database import database
import libraries import libraries
import logger import logger
@@ -42,7 +42,6 @@ import mobile_app
import notification_handler import notification_handler
import notifiers import notifiers
import plextv import plextv
import pmsconnect
import users import users
import versioncheck import versioncheck
import plexpy.config import plexpy.config
@@ -83,6 +82,7 @@ INSTALL_TYPE = None
CURRENT_VERSION = None CURRENT_VERSION = None
LATEST_VERSION = None LATEST_VERSION = None
COMMITS_BEHIND = None COMMITS_BEHIND = None
PREV_RELEASE = None
UMASK = None UMASK = None
@@ -102,7 +102,9 @@ def initialize(config_file):
global _INITIALIZED global _INITIALIZED
global CURRENT_VERSION global CURRENT_VERSION
global LATEST_VERSION global LATEST_VERSION
global PREV_RELEASE
global UMASK global UMASK
CONFIG = plexpy.config.Config(config_file) CONFIG = plexpy.config.Config(config_file)
CONFIG_FILE = config_file CONFIG_FILE = config_file
@@ -175,17 +177,32 @@ def initialize(config_file):
# Check if Tautulli has a uuid # Check if Tautulli has a uuid
if CONFIG.PMS_UUID == '' or not CONFIG.PMS_UUID: if CONFIG.PMS_UUID == '' or not CONFIG.PMS_UUID:
logger.debug(u"Generating UUID...") logger.debug(u"Generating UUID...")
my_uuid = generate_uuid() CONFIG.PMS_UUID = generate_uuid()
CONFIG.__setattr__('PMS_UUID', my_uuid)
CONFIG.write() CONFIG.write()
# Check if Tautulli has an API key # Check if Tautulli has an API key
if CONFIG.API_KEY == '': if CONFIG.API_KEY == '':
logger.debug(u"Generating API key...") logger.debug(u"Generating API key...")
api_key = generate_uuid() CONFIG.API_KEY = generate_uuid()
CONFIG.__setattr__('API_KEY', api_key)
CONFIG.write() CONFIG.write()
# Check if Tautulli has a jwt_secret
if CONFIG.JWT_SECRET == '' or not CONFIG.JWT_SECRET:
logger.debug(u"Generating JWT secret...")
CONFIG.JWT_SECRET = generate_uuid()
CONFIG.write()
# Get the previous version from the file
version_lock_file = os.path.join(DATA_DIR, "version.lock")
prev_version = None
if os.path.isfile(version_lock_file):
try:
with open(version_lock_file, "r") as fp:
prev_version = fp.read()
except IOError as e:
logger.error(u"Unable to read previous version from file '%s': %s" %
(version_lock_file, e))
# Get the currently installed version. Returns None, 'win32' or the git # Get the currently installed version. Returns None, 'win32' or the git
# hash. # hash.
CURRENT_VERSION, CONFIG.GIT_REMOTE, CONFIG.GIT_BRANCH = versioncheck.getVersion() CURRENT_VERSION, CONFIG.GIT_REMOTE, CONFIG.GIT_BRANCH = versioncheck.getVersion()
@@ -194,8 +211,6 @@ def initialize(config_file):
# This allowes one to restore to that version. The idea is that if we # This allowes one to restore to that version. The idea is that if we
# arrive here, most parts of Tautulli seem to work. # arrive here, most parts of Tautulli seem to work.
if CURRENT_VERSION: if CURRENT_VERSION:
version_lock_file = os.path.join(DATA_DIR, "version.lock")
try: try:
with open(version_lock_file, "w") as fp: with open(version_lock_file, "w") as fp:
fp.write(CURRENT_VERSION) fp.write(CURRENT_VERSION)
@@ -213,6 +228,32 @@ def initialize(config_file):
else: else:
LATEST_VERSION = CURRENT_VERSION LATEST_VERSION = CURRENT_VERSION
# Get the previous release from the file
release_file = os.path.join(DATA_DIR, "release.lock")
PREV_RELEASE = common.VERSION_NUMBER
if os.path.isfile(release_file):
try:
with open(release_file, "r") as fp:
PREV_RELEASE = fp.read()
except IOError as e:
logger.error(u"Unable to read previous release from file '%s': %s" %
(release_file, e))
elif prev_version == 'cfd30996264b7e9fe4ef87f02d1cc52d1ae8bfca': # Commit hash for v1.4.25
PREV_RELEASE = 'v1.4.25'
# Check if the release was updated
if common.VERSION_NUMBER != PREV_RELEASE:
CONFIG.UPDATE_SHOW_CHANGELOG = 1
CONFIG.write()
# Write current release version to file for update checking
try:
with open(release_file, "w") as fp:
fp.write(common.VERSION_NUMBER)
except IOError as e:
logger.error(u"Unable to write current release to file '%s': %s" %
(release_file, e))
# Get the real PMS urls for SSL and remote access # Get the real PMS urls for SSL and remote access
if CONFIG.PMS_TOKEN and CONFIG.PMS_IP and CONFIG.PMS_PORT: if CONFIG.PMS_TOKEN and CONFIG.PMS_IP and CONFIG.PMS_PORT:
plextv.get_server_resources() plextv.get_server_resources()
@@ -406,6 +447,7 @@ def start():
# Start background notification thread # Start background notification thread
notification_handler.start_threads(num_threads=CONFIG.NOTIFICATION_THREADS) notification_handler.start_threads(num_threads=CONFIG.NOTIFICATION_THREADS)
notifiers.check_browser_enabled()
_STARTED = True _STARTED = True
@@ -498,7 +540,7 @@ def dbcheck():
c_db.execute( c_db.execute(
'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, ' 'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'user_id INTEGER DEFAULT NULL UNIQUE, username TEXT NOT NULL, friendly_name TEXT, ' 'user_id INTEGER DEFAULT NULL UNIQUE, username TEXT NOT NULL, friendly_name TEXT, '
'thumb TEXT, custom_avatar_url TEXT, email TEXT, is_home_user INTEGER DEFAULT NULL, ' 'thumb TEXT, custom_avatar_url TEXT, email TEXT, is_admin INTEGER DEFAULT 0, is_home_user INTEGER DEFAULT NULL, '
'is_allow_sync INTEGER DEFAULT NULL, is_restricted INTEGER DEFAULT NULL, do_notify INTEGER DEFAULT 1, ' 'is_allow_sync INTEGER DEFAULT NULL, is_restricted INTEGER DEFAULT NULL, do_notify INTEGER DEFAULT 1, '
'keep_history INTEGER DEFAULT 1, deleted_user INTEGER DEFAULT 0, allow_guest INTEGER DEFAULT 0, ' 'keep_history INTEGER DEFAULT 1, deleted_user INTEGER DEFAULT 0, allow_guest INTEGER DEFAULT 0, '
'user_token TEXT, server_token TEXT, shared_libraries TEXT, filter_all TEXT, filter_movies TEXT, filter_tv TEXT, ' 'user_token TEXT, server_token TEXT, shared_libraries TEXT, filter_all TEXT, filter_movies TEXT, filter_tv TEXT, '
@@ -1285,6 +1327,15 @@ def dbcheck():
'ALTER TABLE users ADD COLUMN filter_photos TEXT' 'ALTER TABLE users ADD COLUMN filter_photos TEXT'
) )
# Upgrade users table from earlier versions
try:
c_db.execute('SELECT is_admin FROM users')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table users.")
c_db.execute(
'ALTER TABLE users ADD COLUMN is_admin INTEGER DEFAULT 0'
)
# Upgrade notify_log table from earlier versions # Upgrade notify_log table from earlier versions
try: try:
c_db.execute('SELECT poster_url FROM notify_log') c_db.execute('SELECT poster_url FROM notify_log')
@@ -1536,6 +1587,7 @@ def upgrade():
def shutdown(restart=False, update=False, checkout=False): def shutdown(restart=False, update=False, checkout=False):
cherrypy.engine.exit() cherrypy.engine.exit()
SCHED.shutdown(wait=False) SCHED.shutdown(wait=False)
activity_handler.ACTIVITY_SCHED.shutdown(wait=False)
# Stop the notification threads # Stop the notification threads
for i in range(CONFIG.NOTIFICATION_THREADS): for i in range(CONFIG.NOTIFICATION_THREADS):
@@ -1566,23 +1618,35 @@ def shutdown(restart=False, update=False, checkout=False):
if restart: if restart:
logger.info(u"Tautulli is restarting...") logger.info(u"Tautulli is restarting...")
exe = sys.executable exe = sys.executable
args = [exe, FULL_PATH] args = [exe, FULL_PATH]
args += ARGS args += ARGS
if '--nolaunch' not in args: if '--nolaunch' not in args:
args += ['--nolaunch'] args += ['--nolaunch']
# os.execv fails with spaced names on Windows # Separate out logger so we can shutdown logger after
# https://bugs.python.org/issue19066
if NOFORK: if NOFORK:
logger.info('Running as service, not forking. Exiting...') logger.info('Running as service, not forking. Exiting...')
elif os.name == 'nt': elif os.name == 'nt':
logger.info('Restarting Tautulli with %s', args) logger.info('Restarting Tautulli with %s', args)
subprocess.Popen(args, cwd=os.getcwd())
else: else:
logger.info('Restarting Tautulli with %s', args) logger.info('Restarting Tautulli with %s', args)
logger.shutdown()
# os.execv fails with spaced names on Windows
# https://bugs.python.org/issue19066
if NOFORK:
pass
elif os.name == 'nt':
subprocess.Popen(args, cwd=os.getcwd())
else:
os.execv(exe, args) os.execv(exe, args)
else:
logger.shutdown()
os._exit(0) os._exit(0)

View File

@@ -121,13 +121,19 @@ class ActivityHandler(object):
# Write it to the history table # Write it to the history table
monitor_proc = activity_processor.ActivityProcessor() monitor_proc = activity_processor.ActivityProcessor()
monitor_proc.write_session_history(session=db_session) row_id = monitor_proc.write_session_history(session=db_session)
# Remove the session from our temp session table if row_id:
logger.debug(u"Tautulli ActivityHandler :: Removing sessionKey %s ratingKey %s from session queue" schedule_callback('session_key-{}'.format(self.get_session_key()), remove_job=True)
% (str(self.get_session_key()), str(self.get_rating_key())))
ap.delete_session(session_key=self.get_session_key()) # Remove the session from our temp session table
delete_metadata_cache(self.get_session_key()) logger.debug(u"Tautulli ActivityHandler :: Removing sessionKey %s ratingKey %s from session queue"
% (str(self.get_session_key()), str(self.get_rating_key())))
ap.delete_session(row_id=row_id)
delete_metadata_cache(self.get_session_key())
else:
schedule_callback('session_key-{}'.format(self.get_session_key()), func=force_stop_stream,
args=[self.get_session_key()], seconds=30)
def on_pause(self, still_paused=False): def on_pause(self, still_paused=False):
if self.is_valid_session(): if self.is_valid_session():
@@ -245,9 +251,6 @@ class ActivityHandler(object):
elif this_state == 'stopped': elif this_state == 'stopped':
self.on_stop() self.on_stop()
# Remove the callback if the stream is stopped
schedule_callback('session_key-{}'.format(self.get_session_key()), remove_job=True)
elif this_state == 'buffering': elif this_state == 'buffering':
self.on_buffer() self.on_buffer()
@@ -324,6 +327,7 @@ class TimelineHandler(object):
9: 'album', 9: 'album',
10: 'track'} 10: 'track'}
identifier = self.timeline.get('identifier')
state_type = self.timeline.get('state') state_type = self.timeline.get('state')
media_type = media_types.get(self.timeline.get('type')) media_type = media_types.get(self.timeline.get('type'))
section_id = self.timeline.get('sectionID', 0) section_id = self.timeline.get('sectionID', 0)
@@ -332,6 +336,10 @@ class TimelineHandler(object):
media_state = self.timeline.get('mediaState') media_state = self.timeline.get('mediaState')
queue_size = self.timeline.get('queueSize') queue_size = self.timeline.get('queueSize')
# Return if it is not a library event (i.e. DVR EPG event)
if identifier != 'com.plexapp.plugins.library':
return
# Add a new media item to the recently added queue # Add a new media item to the recently added queue
if media_type and section_id > 0 and \ if media_type and section_id > 0 and \
((state_type == 0 and metadata_state == 'created')): # or \ ((state_type == 0 and metadata_state == 'created')): # or \
@@ -433,13 +441,14 @@ def force_stop_stream(session_key):
ap = activity_processor.ActivityProcessor() ap = activity_processor.ActivityProcessor()
session = ap.get_session_by_key(session_key=session_key) session = ap.get_session_by_key(session_key=session_key)
success = ap.write_session_history(session=session) row_id = ap.write_session_history(session=session)
if success: if row_id:
# If session is written to the databaase successfully, remove the session from the session table # If session is written to the databaase successfully, remove the session from the session table
logger.info(u"Tautulli ActivityHandler :: Removing stale stream with sessionKey %s ratingKey %s from session queue" logger.info(u"Tautulli ActivityHandler :: Removing stale stream with sessionKey %s ratingKey %s from session queue"
% (session['session_key'], session['rating_key'])) % (session['session_key'], session['rating_key']))
ap.delete_session(session_key=session_key) ap.delete_session(row_id=row_id)
delete_metadata_cache(session_key)
else: else:
session['write_attempts'] += 1 session['write_attempts'] += 1
@@ -451,7 +460,7 @@ def force_stop_stream(session_key):
ap.increment_write_attempts(session_key=session_key) ap.increment_write_attempts(session_key=session_key)
# Reschedule for 30 seconds later # Reschedule for 30 seconds later
schedule_callback('session_key={}'.format(session_key), function=force_stop_stream, schedule_callback('session_key-{}'.format(session_key), function=force_stop_stream,
args=[session_key], seconds=30) args=[session_key], seconds=30)
else: else:
@@ -500,14 +509,16 @@ def on_created(rating_key, **kwargs):
notify = True notify = True
now = int(time.time()) now = int(time.time())
if helpers.cast_to_int(metadata['updated_at']) < now - 86400: # Updated more than 24 hours ago if helpers.cast_to_int(metadata['added_at']) < now - 86400: # Updated more than 24 hours ago
logger.debug(u"Tautulli TimelineHandler :: Library item %s updated more than 24 hours ago. Not notifying." % str(rating_key)) logger.debug(u"Tautulli TimelineHandler :: Library item %s added more than 24 hours ago. Not notifying."
% str(rating_key))
notify = False notify = False
data_factory = datafactory.DataFactory() data_factory = datafactory.DataFactory()
if 'child_keys' not in kwargs: if 'child_keys' not in kwargs:
if data_factory.get_recently_added_item(rating_key): if data_factory.get_recently_added_item(rating_key):
logger.debug(u"Tautulli TimelineHandler :: Library item %s added already. Not notifying again." % str(rating_key)) logger.debug(u"Tautulli TimelineHandler :: Library item %s added already. Not notifying again."
% str(rating_key))
notify = False notify = False
if notify: if notify:

View File

@@ -160,14 +160,13 @@ def check_active_sessions(ws_request=False):
plexpy.NOTIFY_QUEUE.put({'stream_data': stream, 'notify_action': 'on_stop'}) plexpy.NOTIFY_QUEUE.put({'stream_data': stream, 'notify_action': 'on_stop'})
# Write the item history on playback stop # Write the item history on playback stop
success = monitor_process.write_session_history(session=stream) row_id = monitor_process.write_session_history(session=stream)
if success: if row_id:
# If session is written to the databaase successfully, remove the session from the session table # If session is written to the databaase successfully, remove the session from the session table
logger.debug(u"Tautulli Monitor :: Removing sessionKey %s ratingKey %s from session queue" logger.debug(u"Tautulli Monitor :: Removing sessionKey %s ratingKey %s from session queue"
% (stream['session_key'], stream['rating_key'])) % (stream['session_key'], stream['rating_key']))
monitor_db.action('DELETE FROM sessions WHERE session_key = ? AND rating_key = ?', monitor_process.delete_session(row_id=row_id)
[stream['session_key'], stream['rating_key']])
else: else:
stream['write_attempts'] += 1 stream['write_attempts'] += 1
@@ -175,18 +174,14 @@ def check_active_sessions(ws_request=False):
logger.warn(u"Tautulli Monitor :: Failed to write sessionKey %s ratingKey %s to the database. " \ logger.warn(u"Tautulli Monitor :: Failed to write sessionKey %s ratingKey %s to the database. " \
"Will try again on the next pass. Write attempt %s." "Will try again on the next pass. Write attempt %s."
% (stream['session_key'], stream['rating_key'], str(stream['write_attempts']))) % (stream['session_key'], stream['rating_key'], str(stream['write_attempts'])))
monitor_db.action('UPDATE sessions SET write_attempts = ? ' monitor_process.increment_write_attempts(session_key=stream['session_key'])
'WHERE session_key = ? AND rating_key = ?',
[stream['write_attempts'], stream['session_key'], stream['rating_key']])
else: else:
logger.warn(u"Tautulli Monitor :: Failed to write sessionKey %s ratingKey %s to the database. " \ logger.warn(u"Tautulli Monitor :: Failed to write sessionKey %s ratingKey %s to the database. " \
"Removing session from the database. Write attempt %s." "Removing session from the database. Write attempt %s."
% (stream['session_key'], stream['rating_key'], str(stream['write_attempts']))) % (stream['session_key'], stream['rating_key'], str(stream['write_attempts'])))
logger.debug(u"Tautulli Monitor :: Removing sessionKey %s ratingKey %s from session queue" logger.debug(u"Tautulli Monitor :: Removing sessionKey %s ratingKey %s from session queue"
% (stream['session_key'], stream['rating_key'])) % (stream['session_key'], stream['rating_key']))
monitor_db.action('DELETE FROM sessions WHERE session_key = ? AND rating_key = ?', monitor_process.delete_session(session_key=stream['session_key'])
[stream['session_key'], stream['rating_key']])
# Process the newly received session data # Process the newly received session data
for session in media_container: for session in media_container:

View File

@@ -155,7 +155,12 @@ class ActivityProcessor(object):
# Reload json from raw stream info # Reload json from raw stream info
if session.get('raw_stream_info'): if session.get('raw_stream_info'):
session.update(json.loads(session['raw_stream_info'])) raw_stream_info = json.loads(session['raw_stream_info'])
# Don't overwrite id, session_key, stopped
raw_stream_info.pop('id', None)
raw_stream_info.pop('session_key', None)
raw_stream_info.pop('stopped', None)
session.update(raw_stream_info)
session = defaultdict(str, session) session = defaultdict(str, session)
@@ -177,6 +182,7 @@ class ActivityProcessor(object):
else: else:
logger.debug(u"Tautulli ActivityProcessor :: ratingKey %s not logged. Does not meet logging criteria. " logger.debug(u"Tautulli ActivityProcessor :: ratingKey %s not logged. Does not meet logging criteria. "
u"Media type is '%s'" % (session['rating_key'], session['media_type'])) u"Media type is '%s'" % (session['rating_key'], session['media_type']))
return session['id']
if str(session['paused_counter']).isdigit(): if str(session['paused_counter']).isdigit():
real_play_time = stopped - session['started'] - int(session['paused_counter']) real_play_time = stopped - session['started'] - int(session['paused_counter'])
@@ -284,7 +290,7 @@ class ActivityProcessor(object):
query = 'UPDATE session_history SET reference_id = ? WHERE id = ? ' query = 'UPDATE session_history SET reference_id = ? WHERE id = ? '
# If rating_key is the same in the previous session, then set the reference_id to the previous row, else set the reference_id to the new id # If rating_key is the same in the previous session, then set the reference_id to the previous row, else set the reference_id to the new id
if prev_session == new_session == None: if prev_session is None and new_session is None:
args = [last_id, last_id] args = [last_id, last_id]
elif prev_session['rating_key'] == new_session['rating_key'] and prev_session['view_offset'] <= new_session['view_offset']: elif prev_session['rating_key'] == new_session['rating_key'] and prev_session['view_offset'] <= new_session['view_offset']:
args = [prev_session['reference_id'], new_session['id']] args = [prev_session['reference_id'], new_session['id']]
@@ -414,8 +420,8 @@ class ActivityProcessor(object):
# logger.debug(u"Tautulli ActivityProcessor :: Writing session_history_metadata transaction...") # logger.debug(u"Tautulli ActivityProcessor :: Writing session_history_metadata transaction...")
self.db.upsert(table_name='session_history_metadata', key_dict=keys, value_dict=values) self.db.upsert(table_name='session_history_metadata', key_dict=keys, value_dict=values)
# Return true when the session is successfully written to the database # Return the session row id when the session is successfully written to the database
return True return session['id']
def get_sessions(self, user_id=None, ip_address=None): def get_sessions(self, user_id=None, ip_address=None):
query = 'SELECT * FROM sessions' query = 'SELECT * FROM sessions'
@@ -456,9 +462,11 @@ class ActivityProcessor(object):
return None return None
def delete_session(self, session_key=None): def delete_session(self, session_key=None, row_id=None):
if str(session_key).isdigit(): if str(session_key).isdigit():
self.db.action('DELETE FROM sessions WHERE session_key = ?', [session_key]) self.db.action('DELETE FROM sessions WHERE session_key = ?', [session_key])
elif str(row_id).isdigit():
self.db.action('DELETE FROM sessions WHERE id = ?', [row_id])
def set_session_last_paused(self, session_key=None, timestamp=None): def set_session_last_paused(self, session_key=None, timestamp=None):
if str(session_key).isdigit(): if str(session_key).isdigit():

View File

@@ -388,10 +388,10 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Track Number 00', 'type': 'int', 'value': 'track_num00', 'description': 'The two digit track number.', 'example': 'e.g. 04, or 04-10'}, {'name': 'Track Number 00', 'type': 'int', 'value': 'track_num00', 'description': 'The two digit track number.', 'example': 'e.g. 04, or 04-10'},
{'name': 'Year', 'type': 'int', 'value': 'year', 'description': 'The release year for the item.'}, {'name': 'Year', 'type': 'int', 'value': 'year', 'description': 'The release year for the item.'},
{'name': 'Release Date', 'type': 'int', 'value': 'release_date', 'description': 'The release date (in date format) for the item.'}, {'name': 'Release Date', 'type': 'int', 'value': 'release_date', 'description': 'The release date (in date format) for the item.'},
{'name': 'Air Date', 'type': 'int', 'value': 'air_date', 'description': 'The air date (in date format) for the item.'}, {'name': 'Air Date', 'type': 'str', 'value': 'air_date', 'description': 'The air date (in date format) for the item.'},
{'name': 'Added Date', 'type': 'int', 'value': 'added_date', 'description': 'The date (in date format) the item was added to Plex.'}, {'name': 'Added Date', 'type': 'str', 'value': 'added_date', 'description': 'The date (in date format) the item was added to Plex.'},
{'name': 'Updated Date', 'type': 'int', 'value': 'updated_date', 'description': 'The date (in date format) the item was updated on Plex.'}, {'name': 'Updated Date', 'type': 'str', 'value': 'updated_date', 'description': 'The date (in date format) the item was updated on Plex.'},
{'name': 'Last Viewed Date', 'type': 'int', 'value': 'last_viewed_date', 'description': 'The date (in date format) the item was last viewed on Plex.'}, {'name': 'Last Viewed Date', 'type': 'str', 'value': 'last_viewed_date', 'description': 'The date (in date format) the item was last viewed on Plex.'},
{'name': 'Studio', 'type': 'str', 'value': 'studio', 'description': 'The studio for the item.'}, {'name': 'Studio', 'type': 'str', 'value': 'studio', 'description': 'The studio for the item.'},
{'name': 'Content Rating', 'type': 'int', 'value': 'content_rating', 'description': 'The content rating for the item.', 'example': 'e.g. TV-MA, TV-PG, etc.'}, {'name': 'Content Rating', 'type': 'int', 'value': 'content_rating', 'description': 'The content rating for the item.', 'example': 'e.g. TV-MA, TV-PG, etc.'},
{'name': 'Director', 'type': 'str', 'value': 'directors', 'description': 'A list of directors for the item.'}, {'name': 'Director', 'type': 'str', 'value': 'directors', 'description': 'A list of directors for the item.'},
@@ -400,8 +400,8 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Genre', 'type': 'str', 'value': 'genres', 'description': 'A list of genres for the item.'}, {'name': 'Genre', 'type': 'str', 'value': 'genres', 'description': 'A list of genres for the item.'},
{'name': 'Summary', 'type': 'str', 'value': 'summary', 'description': 'A short plot summary for the item.'}, {'name': 'Summary', 'type': 'str', 'value': 'summary', 'description': 'A short plot summary for the item.'},
{'name': 'Tagline', 'type': 'str', 'value': 'tagline', 'description': 'A tagline for the media item.'}, {'name': 'Tagline', 'type': 'str', 'value': 'tagline', 'description': 'A tagline for the media item.'},
{'name': 'Rating', 'type': 'int', 'value': 'rating', 'description': 'The rating (out of 10) for the item.'}, {'name': 'Rating', 'type': 'float', 'value': 'rating', 'description': 'The rating (out of 10) for the item.'},
{'name': 'Audience Rating', 'type': 'int', 'value': 'audience_rating', 'description': 'The audience rating (%) for the item.', 'help_text': 'Ratings source must be Rotten Tomatoes for the Plex Movie agent'}, {'name': 'Audience Rating', 'type': 'float', 'value': 'audience_rating', 'description': 'The audience rating (%) for the item.', 'help_text': 'Ratings source must be Rotten Tomatoes for the Plex Movie agent'},
{'name': 'Duration', 'type': 'int', 'value': 'duration', 'description': 'The duration (in minutes) for the item.'}, {'name': 'Duration', 'type': 'int', 'value': 'duration', 'description': 'The duration (in minutes) for the item.'},
{'name': 'Poster URL', 'type': 'str', 'value': 'poster_url', 'description': 'A URL for the movie, TV show, or album poster.'}, {'name': 'Poster URL', 'type': 'str', 'value': 'poster_url', 'description': 'A URL for the movie, TV show, or album poster.'},
{'name': 'Plex URL', 'type': 'str', 'value': 'plex_url', 'description': 'The Plex URL to your server for the item.'}, {'name': 'Plex URL', 'type': 'str', 'value': 'plex_url', 'description': 'The Plex URL to your server for the item.'},
@@ -477,12 +477,12 @@ NOTIFICATION_PARAMETERS = [
{ {
'category': 'Tautulli Update Available', 'category': 'Tautulli Update Available',
'parameters': [ 'parameters': [
{'name': 'Plexpy Update Version', 'type': 'int', 'value': 'plexpy_update_version', 'description': 'The available update version for Tautulli.'}, {'name': 'Tautulli Update Version', 'type': 'int', 'value': 'tautulli_update_version', 'description': 'The available update version for Tautulli.'},
{'name': 'Plexpy Update Tar', 'type': 'int', 'value': 'plexpy_update_tar', 'description': 'The tar download URL for the available update.'}, {'name': 'Tautulli Update Tar', 'type': 'int', 'value': 'tautulli_update_tar', 'description': 'The tar download URL for the available update.'},
{'name': 'Plexpy Update Zip', 'type': 'int', 'value': 'plexpy_update_zip', 'description': 'The zip download URL for the available update.'}, {'name': 'Tautulli Update Zip', 'type': 'int', 'value': 'tautulli_update_zip', 'description': 'The zip download URL for the available update.'},
{'name': 'Plexpy Update Commit', 'type': 'int', 'value': 'plexpy_update_commit', 'description': 'The commit hash for the available update.'}, {'name': 'Tautulli Update Commit', 'type': 'int', 'value': 'tautulli_update_commit', 'description': 'The commit hash for the available update.'},
{'name': 'Plexpy Update Behind', 'type': 'int', 'value': 'plexpy_update_behind', 'description': 'The number of commits behind for the available update.'}, {'name': 'Tautulli Update Behind', 'type': 'int', 'value': 'tautulli_update_behind', 'description': 'The number of commits behind for the available update.'},
{'name': 'Plexpy Update Changelog', 'type': 'int', 'value': 'plexpy_update_changelog', 'description': 'The changelog for the available update.'}, {'name': 'Tautulli Update Changelog', 'type': 'int', 'value': 'tautulli_update_changelog', 'description': 'The changelog for the available update.'},
] ]
}, },
] ]

View File

@@ -225,6 +225,7 @@ _CONFIG_DEFINITIONS = {
'HTTP_PROXY': (int, 'General', 0), 'HTTP_PROXY': (int, 'General', 0),
'HTTP_ROOT': (str, 'General', ''), 'HTTP_ROOT': (str, 'General', ''),
'HTTP_USERNAME': (str, 'General', ''), 'HTTP_USERNAME': (str, 'General', ''),
'HTTP_PLEX_ADMIN': (int, 'General', 0),
'HIPCHAT_URL': (str, 'Hipchat', ''), 'HIPCHAT_URL': (str, 'Hipchat', ''),
'HIPCHAT_COLOR': (str, 'Hipchat', ''), 'HIPCHAT_COLOR': (str, 'Hipchat', ''),
'HIPCHAT_INCL_SUBJECT': (int, 'Hipchat', 1), 'HIPCHAT_INCL_SUBJECT': (int, 'Hipchat', 1),
@@ -611,7 +612,8 @@ _CONFIG_DEFINITIONS = {
'XBMC_ON_INTUP': (int, 'XBMC', 0), 'XBMC_ON_INTUP': (int, 'XBMC', 0),
'XBMC_ON_PMSUPDATE': (int, 'XBMC', 0), 'XBMC_ON_PMSUPDATE': (int, 'XBMC', 0),
'XBMC_ON_CONCURRENT': (int, 'XBMC', 0), 'XBMC_ON_CONCURRENT': (int, 'XBMC', 0),
'XBMC_ON_NEWDEVICE': (int, 'XBMC', 0) 'XBMC_ON_NEWDEVICE': (int, 'XBMC', 0),
'JWT_SECRET': (str, 'Advanced', ''),
} }
_BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID', '_HOOK'] _BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID', '_HOOK']

View File

@@ -306,6 +306,11 @@ def initHooks(global_exceptions=True, thread_exceptions=True, pass_original=True
# Monkey patch the run() by monkey patching the __init__ method # Monkey patch the run() by monkey patching the __init__ method
threading.Thread.__init__ = new_init threading.Thread.__init__ = new_init
def shutdown():
logging.shutdown()
# Expose logger methods # Expose logger methods
# Main Tautulli logger # Main Tautulli logger
info = logger.info info = logger.info

View File

@@ -523,10 +523,15 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
remaining_duration = duration - view_offset remaining_duration = duration - view_offset
# Build Plex URL # Build Plex URL
if notify_params['media_type'] == 'track':
plex_web_rating_key = notify_params['parent_rating_key']
else:
plex_web_rating_key = notify_params['rating_key']
notify_params['plex_url'] = '{web_url}#!/server/{pms_identifier}/details?key=%2Flibrary%2Fmetadata%2F{rating_key}'.format( notify_params['plex_url'] = '{web_url}#!/server/{pms_identifier}/details?key=%2Flibrary%2Fmetadata%2F{rating_key}'.format(
web_url=plexpy.CONFIG.PMS_WEB_URL, web_url=plexpy.CONFIG.PMS_WEB_URL,
pms_identifier=plexpy.CONFIG.PMS_IDENTIFIER, pms_identifier=plexpy.CONFIG.PMS_IDENTIFIER,
rating_key=rating_key) rating_key=plex_web_rating_key)
# Get media IDs from guid and build URLs # Get media IDs from guid and build URLs
if 'imdb://' in notify_params['guid']: if 'imdb://' in notify_params['guid']:
@@ -535,7 +540,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
notify_params['trakt_url'] = 'https://trakt.tv/search/imdb/' + notify_params['imdb_id'] notify_params['trakt_url'] = 'https://trakt.tv/search/imdb/' + notify_params['imdb_id']
if 'thetvdb://' in notify_params['guid']: if 'thetvdb://' in notify_params['guid']:
notify_params['thetvdb_id'] = notify_params['guid'].split('thetvdb://')[1].split('/')[0] notify_params['thetvdb_id'] = notify_params['guid'].split('thetvdb://')[1].split('/')[0].split('?')[0]
notify_params['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + notify_params['thetvdb_id'] notify_params['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + notify_params['thetvdb_id']
notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_id'] + '?id_type=show' notify_params['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_id'] + '?id_type=show'

View File

@@ -60,9 +60,13 @@ import logger
import mobile_app import mobile_app
import pmsconnect import pmsconnect
import request import request
import users
from plexpy.config import _BLACKLIST_KEYS, _WHITELIST_KEYS from plexpy.config import _BLACKLIST_KEYS, _WHITELIST_KEYS
BROWSER_NOTIFIERS = {}
AGENT_IDS = {'growl': 0, AGENT_IDS = {'growl': 0,
'prowl': 1, 'prowl': 1,
'xbmc': 2, 'xbmc': 2,
@@ -550,6 +554,10 @@ def set_notifier_config(notifier_id=None, agent_id=None, **kwargs):
db.upsert(table_name='notifiers', key_dict=keys, value_dict=values) db.upsert(table_name='notifiers', key_dict=keys, value_dict=values)
logger.info(u"Tautulli Notifiers :: Updated notification agent: %s (notifier_id %s)." % (agent['label'], notifier_id)) logger.info(u"Tautulli Notifiers :: Updated notification agent: %s (notifier_id %s)." % (agent['label'], notifier_id))
blacklist_logger() blacklist_logger()
if agent['name'] == 'browser':
check_browser_enabled()
return True return True
except Exception as e: except Exception as e:
logger.warn(u"Tautulli Notifiers :: Unable to update notification agent: %s." % e) logger.warn(u"Tautulli Notifiers :: Unable to update notification agent: %s." % e)
@@ -623,9 +631,9 @@ class PrettyMetadata(object):
poster_url = self.parameters['poster_url'] poster_url = self.parameters['poster_url']
if not poster_url: if not poster_url:
if self.media_type in ('artist', 'album', 'track'): if self.media_type in ('artist', 'album', 'track'):
poster_url = 'https://raw.githubusercontent.com/%s/plexpy/master/data/interfaces/default/images/cover.png' % plexpy.CONFIG.GIT_USER poster_url = 'http://tautulli.com/images/cover.png'
else: else:
poster_url = 'https://raw.githubusercontent.com/%s/plexpy/master/data/interfaces/default/images/poster.png' % plexpy.CONFIG.GIT_USER poster_url = 'http://tautulli.com/images/poster.png'
return poster_url return poster_url
def get_provider_name(self, provider): def get_provider_name(self, provider):
@@ -714,6 +722,17 @@ class Notifier(object):
return new_config return new_config
def notify(self, subject='', body='', action='', **kwargs): def notify(self, subject='', body='', action='', **kwargs):
if self.NAME != 'Script':
if not subject and self.config.get('incl_subject', True):
logger.error(u"Tautulli Notifiers :: %s notification subject cannot be blank." % self.NAME)
return
elif not body:
logger.error(u"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 pass
def make_request(self, url, method='POST', **kwargs): def make_request(self, url, method='POST', **kwargs):
@@ -754,10 +773,7 @@ class ANDROIDAPP(Notifier):
_ONESIGNAL_APP_ID = '3b4b666a-d557-4b92-acdf-e2c8c4b95357' _ONESIGNAL_APP_ID = '3b4b666a-d557-4b92-acdf-e2c8c4b95357'
def notify(self, subject='', body='', action='', notification_id=None, **kwargs): def agent_notify(self, subject='', body='', action='', notification_id=None, **kwargs):
if not subject or not body:
return
# Check mobile device is still registered # Check mobile device is still registered
device = mobile_app.get_mobile_devices(device_id=self.config['device_id']) device = mobile_app.get_mobile_devices(device_id=self.config['device_id'])
if not device: if not device:
@@ -918,10 +934,7 @@ class BOXCAR(Notifier):
'sound': '' 'sound': ''
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
data = {'user_credentials': self.config['token'], data = {'user_credentials': self.config['token'],
'notification[title]': subject.encode('utf-8'), 'notification[title]': subject.encode('utf-8'),
'notification[long_message]': body.encode('utf-8'), 'notification[long_message]': body.encode('utf-8'),
@@ -988,43 +1001,15 @@ class BROWSER(Notifier):
Browser notifications Browser notifications
""" """
NAME = 'Browser' NAME = 'Browser'
_DEFAULT_CONFIG = {'enabled': 0, _DEFAULT_CONFIG = {'auto_hide_delay': 5
'auto_hide_delay': 5
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
logger.info(u"Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME)) logger.info(u"Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
return True return True
def get_notifications(self):
if not self.config['enabled']:
return
db = database.MonitorDatabase()
result = db.select('SELECT subject_text, body_text FROM notify_log '
'WHERE agent_id = 17 AND timestamp >= ? ',
args=[time.time() - 3])
notifications = []
for item in result:
notification = {'subject_text': item['subject_text'],
'body_text': item['body_text'],
'delay': self.config['auto_hide_delay']}
notifications.append(notification)
return {'notifications': notifications}
def return_config_options(self): def return_config_options(self):
config_option = [{'label': 'Enable Browser Notifications', config_option = [{'label': 'Allow Notifications',
'value': self.config['enabled'],
'name': 'browser_enabled',
'description': 'Enable to display desktop notifications from your browser.',
'input_type': 'checkbox'
},
{'label': 'Allow Notifications',
'value': 'Allow Notifications', 'value': 'Allow Notifications',
'name': 'browser_allow_browser', 'name': 'browser_allow_browser',
'description': 'Click to allow browser notifications. You must click this button for each browser.', 'description': 'Click to allow browser notifications. You must click this button for each browser.',
@@ -1062,10 +1047,7 @@ class DISCORD(Notifier):
'music_provider': '' 'music_provider': ''
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
if self.config['incl_subject']: if self.config['incl_subject']:
text = subject.encode('utf-8') + '\r\n' + body.encode("utf-8") text = subject.encode('utf-8') + '\r\n' + body.encode("utf-8")
else: else:
@@ -1178,7 +1160,8 @@ class DISCORD(Notifier):
{'label': 'Include Rich Metadata Info', {'label': 'Include Rich Metadata Info',
'value': self.config['incl_card'], 'value': self.config['incl_card'],
'name': 'discord_incl_card', 'name': 'discord_incl_card',
'description': 'Include an info card with a poster and metadata with the notifications.', 'description': 'Include an info card with a poster and metadata with the notifications.<br>'
'Imgur upload may need to be enabled under the notifications settings tab.',
'input_type': 'checkbox' 'input_type': 'checkbox'
}, },
{'label': 'Include Plot Summaries', {'label': 'Include Plot Summaries',
@@ -1202,16 +1185,16 @@ class DISCORD(Notifier):
{'label': 'Movie Link Source', {'label': 'Movie Link Source',
'value': self.config['movie_provider'], 'value': self.config['movie_provider'],
'name': 'discord_movie_provider', 'name': 'discord_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank for default.<br> \ 'description': 'Select the source for movie links on the info cards. Leave blank for default.<br>'
3rd party API lookup may need to be enabled under the notification settings tab.', '3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers() 'select_options': PrettyMetadata().get_movie_providers()
}, },
{'label': 'TV Show Link Source', {'label': 'TV Show Link Source',
'value': self.config['tv_provider'], 'value': self.config['tv_provider'],
'name': 'discord_tv_provider', 'name': 'discord_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br> \ 'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br>'
3rd party API lookup may need to be enabled under the notification settings tab.', '3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers() 'select_options': PrettyMetadata().get_tv_providers()
}, },
@@ -1245,27 +1228,31 @@ class EMAIL(Notifier):
'html_support': 1 'html_support': 1
} }
def notify(self, subject='', body='', action='', **kwargs): def __init__(self, config=None):
if not subject or not body: super(EMAIL, self).__init__(config=config)
return
if not isinstance(self.config['to'], list):
self.config['to'] = [x.strip() for x in self.config['to'].split(';')]
if not isinstance(self.config['cc'], list):
self.config['cc'] = [x.strip() for x in self.config['cc'].split(';')]
if not isinstance(self.config['bcc'], list):
self.config['bcc'] = [x.strip() for x in self.config['bcc'].split(';')]
def agent_notify(self, subject='', body='', action='', **kwargs):
if self.config['html_support']: if self.config['html_support']:
body = body.replace('\n', '<br />')
msg = MIMEMultipart('alternative') msg = MIMEMultipart('alternative')
msg.attach(MIMEText(bleach.clean(body, strip=True), 'plain', 'utf-8')) msg.attach(MIMEText(bleach.clean(body, strip=True), 'plain', 'utf-8'))
msg.attach(MIMEText(body, 'html', 'utf-8')) msg.attach(MIMEText(body, 'html', 'utf-8'))
else: else:
msg = MIMEText(body, 'plain', 'utf-8') msg = MIMEText(body, 'plain', 'utf-8')
msg['Date'] = email.utils.formatdate(localtime=True)
msg['Subject'] = subject msg['Subject'] = subject
msg['From'] = email.utils.formataddr((self.config['from_name'], self.config['from'])) msg['From'] = email.utils.formataddr((self.config['from_name'], self.config['from']))
msg['To'] = self.config['to'] msg['To'] = ','.join(self.config['to'])
msg['CC'] = self.config['cc'] msg['CC'] = ','.join(self.config['cc'])
recipients = [x.strip() for x in self.config['to'].split(';')] \ recipients = self.config['to'] + self.config['cc'] + self.config['bcc']
+ [x.strip() for x in self.config['cc'].split(';')] \
+ [x.strip() for x in self.config['bcc'].split(';')]
recipients = filter(None, recipients)
try: try:
mailserver = smtplib.SMTP(self.config['smtp_server'], self.config['smtp_port']) mailserver = smtplib.SMTP(self.config['smtp_server'], self.config['smtp_port'])
@@ -1288,7 +1275,26 @@ class EMAIL(Notifier):
logger.error(u"Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e)) logger.error(u"Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e))
return False return False
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.iteritems()]
user_emails_cc = [{'value': k, 'text': v} for k, v in user_emails_cc.iteritems()]
user_emails_bcc = [{'value': k, 'text': v} for k, v in user_emails_bcc.iteritems()]
return user_emails_to, user_emails_cc, user_emails_bcc
def return_config_options(self): def return_config_options(self):
user_emails_to, user_emails_cc, user_emails_bcc = self.get_user_emails()
config_option = [{'label': 'From Name', config_option = [{'label': 'From Name',
'value': self.config['from_name'], 'value': self.config['from_name'],
'name': 'email_from_name', 'name': 'email_from_name',
@@ -1304,20 +1310,23 @@ class EMAIL(Notifier):
{'label': 'To', {'label': 'To',
'value': self.config['to'], 'value': self.config['to'],
'name': 'email_to', 'name': 'email_to',
'description': 'The email address(es) of the recipients, separated by semicolons (;).', 'description': 'The email address(es) of the recipients.',
'input_type': 'text' 'input_type': 'selectize',
'select_options': user_emails_to
}, },
{'label': 'CC', {'label': 'CC',
'value': self.config['cc'], 'value': self.config['cc'],
'name': 'email_cc', 'name': 'email_cc',
'description': 'The email address(es) to CC, separated by semicolons (;).', 'description': 'The email address(es) to CC.',
'input_type': 'text' 'input_type': 'selectize',
'select_options': user_emails_cc
}, },
{'label': 'BCC', {'label': 'BCC',
'value': self.config['bcc'], 'value': self.config['bcc'],
'name': 'email_bcc', 'name': 'email_bcc',
'description': 'The email address(es) to BCC, separated by semicolons (;).', 'description': 'The email address(es) to BCC.',
'input_type': 'text' 'input_type': 'selectize',
'select_options': user_emails_bcc
}, },
{'label': 'SMTP Server', {'label': 'SMTP Server',
'value': self.config['smtp_server'], 'value': self.config['smtp_server'],
@@ -1352,8 +1361,7 @@ class EMAIL(Notifier):
{'label': 'Enable HTML Support', {'label': 'Enable HTML Support',
'value': self.config['html_support'], 'value': self.config['html_support'],
'name': 'email_html_support', 'name': 'email_html_support',
'description': 'Style your messages using HTML tags. ' 'description': 'Style your messages using HTML tags.',
'Line breaks (&lt;br&gt;) will be inserted automatically.',
'input_type': 'checkbox' 'input_type': 'checkbox'
} }
] ]
@@ -1415,7 +1423,7 @@ class FACEBOOK(Notifier):
except Exception as e: except Exception as e:
logger.error(u"Tautulli Notifiers :: Error requesting {name} access token: {e}".format(name=self.NAME, e=e)) logger.error(u"Tautulli Notifiers :: Error requesting {name} access token: {e}".format(name=self.NAME, e=e))
plexpy.CONFIG.FACEBOOK_TOKEN = '' plexpy.CONFIG.FACEBOOK_TOKEN = ''
# Clear out temporary config values # Clear out temporary config values
plexpy.CONFIG.FACEBOOK_APP_ID = '' plexpy.CONFIG.FACEBOOK_APP_ID = ''
plexpy.CONFIG.FACEBOOK_APP_SECRET = '' plexpy.CONFIG.FACEBOOK_APP_SECRET = ''
@@ -1439,10 +1447,7 @@ class FACEBOOK(Notifier):
logger.error(u"Tautulli Notifiers :: Error sending {name} post: No {name} Group ID provided.".format(name=self.NAME)) logger.error(u"Tautulli Notifiers :: Error sending {name} post: No {name} Group ID provided.".format(name=self.NAME))
return False return False
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
if self.config['incl_subject']: if self.config['incl_subject']:
text = subject.encode('utf-8') + '\r\n' + body.encode("utf-8") text = subject.encode('utf-8') + '\r\n' + body.encode("utf-8")
else: else:
@@ -1462,24 +1467,24 @@ class FACEBOOK(Notifier):
provider = self.config['music_provider'] provider = self.config['music_provider']
else: else:
provider = None provider = None
data['link'] = pretty_metadata.get_provider_link(provider) data['link'] = pretty_metadata.get_provider_link(provider)
return self._post_facebook(**data) return self._post_facebook(**data)
def return_config_options(self): def return_config_options(self):
config_option = [{'label': 'Instructions', config_option = [{'label': 'Instructions',
'description': 'Step 1: Visit <a href="' + helpers.anon_url('https://developers.facebook.com/apps') + '" target="_blank"> \ 'description': 'Step 1: Visit <a href="' + helpers.anon_url('https://developers.facebook.com/apps') + '" target="_blank">'
Facebook Developers</a> to add a new app using <strong>basic setup</strong>.<br>\ 'Facebook Developers</a> to add a new app using <strong>basic setup</strong>.<br>'
Step 2: Click <strong>Add Product</strong> on the left, then <strong>Get Started</strong> \ 'Step 2: Click <strong>Add Product</strong> on the left, then <strong>Get Started</strong>'
for <strong>Facebook Login</strong>.<br>\ 'for <strong>Facebook Login</strong>.<br>'
Step 3: Fill in <strong>Valid OAuth redirect URIs</strong> with your Tautulli URL (e.g. http://localhost:8181).<br>\ 'Step 3: Fill in <strong>Valid OAuth redirect URIs</strong> with your Tautulli URL (e.g. http://localhost:8181).<br>'
Step 4: Click <strong>App Review</strong> on the left and toggle "make public" to <strong>Yes</strong>.<br>\ 'Step 4: Click <strong>App Review</strong> on the left and toggle "make public" to <strong>Yes</strong>.<br>'
Step 5: Fill in the <strong>Tautulli URL</strong> below with the exact same URL from Step 3.<br>\ 'Step 5: Fill in the <strong>Tautulli URL</strong> below with the exact same URL from Step 3.<br>'
Step 6: Fill in the <strong>App ID</strong> and <strong>App Secret</strong> below.<br>\ 'Step 6: Fill in the <strong>App ID</strong> and <strong>App Secret</strong> below.<br>'
Step 7: Click the <strong>Request Authorization</strong> button below to retrieve your access token.<br>\ 'Step 7: Click the <strong>Request Authorization</strong> button below to retrieve your access token.<br>'
Step 8: Fill in your <strong>Access Token</strong> below if it is not filled in automatically.<br>\ 'Step 8: Fill in your <strong>Access Token</strong> below if it is not filled in automatically.<br>'
Step 9: Fill in your <strong>Group ID</strong> number below. It can be found in the URL of your group page.', 'Step 9: Fill in your <strong>Group ID</strong> number below. It can be found in the URL of your group page.',
'input_type': 'help' 'input_type': 'help'
}, },
{'label': 'Tautulli URL', {'label': 'Tautulli URL',
@@ -1528,22 +1533,23 @@ class FACEBOOK(Notifier):
{'label': 'Include Rich Metadata Info', {'label': 'Include Rich Metadata Info',
'value': self.config['incl_card'], 'value': self.config['incl_card'],
'name': 'facebook_incl_card', 'name': 'facebook_incl_card',
'description': 'Include an info card with a poster and metadata with the notifications.', 'description': 'Include an info card with a poster and metadata with the notifications.<br>'
'Imgur upload may need to be enabled under the notifications settings tab.',
'input_type': 'checkbox' 'input_type': 'checkbox'
}, },
{'label': 'Movie Link Source', {'label': 'Movie Link Source',
'value': self.config['movie_provider'], 'value': self.config['movie_provider'],
'name': 'facebook_movie_provider', 'name': 'facebook_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank for default.<br> \ 'description': 'Select the source for movie links on the info cards. Leave blank for default.<br>'
3rd party API lookup may need to be enabled under the notification settings tab.', '3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers() 'select_options': PrettyMetadata().get_movie_providers()
}, },
{'label': 'TV Show Link Source', {'label': 'TV Show Link Source',
'value': self.config['tv_provider'], 'value': self.config['tv_provider'],
'name': 'facebook_tv_provider', 'name': 'facebook_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br> \ 'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br>'
3rd party API lookup may need to be enabled under the notification settings tab.', '3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers() 'select_options': PrettyMetadata().get_tv_providers()
}, },
@@ -1570,10 +1576,7 @@ class GROUPME(Notifier):
'incl_poster': 0 'incl_poster': 0
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
data = {'bot_id': self.config['bot_id']} data = {'bot_id': self.config['bot_id']}
if self.config['incl_subject']: if self.config['incl_subject']:
@@ -1648,10 +1651,7 @@ class GROWL(Notifier):
'password': '' 'password': ''
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
# Split host and port # Split host and port
if self.config['host'] == "": if self.config['host'] == "":
host, port = "localhost", 23053 host, port = "localhost", 23053
@@ -1691,7 +1691,7 @@ class GROWL(Notifier):
# Send it, including an image # Send it, including an image
image_file = os.path.join(str(plexpy.PROG_DIR), image_file = os.path.join(str(plexpy.PROG_DIR),
"data/interfaces/default/images/logo.png") "data/interfaces/default/images/logo-circle.png")
with open(image_file, 'rb') as f: with open(image_file, 'rb') as f:
image = f.read() image = f.read()
@@ -1744,10 +1744,7 @@ class HIPCHAT(Notifier):
'music_provider': '' 'music_provider': ''
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
data = {'notify': 'false'} data = {'notify': 'false'}
text = body.encode('utf-8') text = body.encode('utf-8')
@@ -1857,6 +1854,7 @@ class HIPCHAT(Notifier):
'value': self.config['incl_card'], 'value': self.config['incl_card'],
'name': 'hipchat_incl_card', 'name': 'hipchat_incl_card',
'description': 'Include an info card with a poster and metadata with the notifications.<br>' 'description': 'Include an info card with a poster and metadata with the notifications.<br>'
'Imgur upload may need to be enabled under the notifications settings tab.<br>'
'Note: This will change the notification type to HTML and emoticons will no longer work.', 'Note: This will change the notification type to HTML and emoticons will no longer work.',
'input_type': 'checkbox' 'input_type': 'checkbox'
}, },
@@ -1875,16 +1873,16 @@ class HIPCHAT(Notifier):
{'label': 'Movie Link Source', {'label': 'Movie Link Source',
'value': self.config['movie_provider'], 'value': self.config['movie_provider'],
'name': 'hipchat_movie_provider', 'name': 'hipchat_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank for default.<br> \ 'description': 'Select the source for movie links on the info cards. Leave blank for default.<br>'
3rd party API lookup may need to be enabled under the notification settings tab.', '3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers() 'select_options': PrettyMetadata().get_movie_providers()
}, },
{'label': 'TV Show Link Source', {'label': 'TV Show Link Source',
'value': self.config['tv_provider'], 'value': self.config['tv_provider'],
'name': 'hipchat_tv_provider', 'name': 'hipchat_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br> \ 'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br>'
3rd party API lookup may need to be enabled under the notification settings tab.', '3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers() 'select_options': PrettyMetadata().get_tv_providers()
}, },
@@ -1909,10 +1907,7 @@ class IFTTT(Notifier):
'event': 'plexpy' 'event': 'plexpy'
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
event = unicode(self.config['event']).format(action=action) event = unicode(self.config['event']).format(action=action)
data = {'value1': subject.encode("utf-8"), data = {'value1': subject.encode("utf-8"),
@@ -1952,23 +1947,50 @@ class JOIN(Notifier):
""" """
NAME = 'Join' NAME = 'Join'
_DEFAULT_CONFIG = {'api_key': '', _DEFAULT_CONFIG = {'api_key': '',
'device_id': '', 'device_names': '',
'incl_subject': 1 'priority': 2,
'incl_subject': 1,
'incl_poster': 0,
'movie_provider': '',
'tv_provider': '',
'music_provider': ''
} }
def notify(self, subject='', body='', action='', **kwargs): def __init__(self, config=None):
if not subject or not body: super(JOIN, self).__init__(config=config)
return
deviceid_key = 'deviceId%s' % ('s' if len(self.config['device_id'].split(',')) > 1 else '') if not isinstance(self.config['device_names'], list):
self.config['device_names'] = [x.strip() for x in self.config['device_names'].split(',')]
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'apikey': self.config['api_key'], data = {'apikey': self.config['api_key'],
deviceid_key: self.config['device_id'], 'deviceNames': ','.join(self.config['device_names']),
'text': body.encode("utf-8")} 'text': body.encode("utf-8")}
if self.config['incl_subject']: if self.config['incl_subject']:
data['title'] = subject.encode("utf-8") data['title'] = subject.encode("utf-8")
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) r = requests.post('https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush', params=data)
if r.status_code == 200: if r.status_code == 200:
@@ -1986,6 +2008,9 @@ class JOIN(Notifier):
return False return False
def get_devices(self): def get_devices(self):
devices = {d: d for d in self.config['device_names']}
devices.update({'': ''})
if self.config['api_key']: if self.config['api_key']:
params = {'apikey': self.config['api_key']} params = {'apikey': self.config['api_key']}
@@ -1994,28 +2019,22 @@ class JOIN(Notifier):
if r.status_code == 200: if r.status_code == 200:
response_data = r.json() response_data = r.json()
if response_data.get('success'): if response_data.get('success'):
devices = response_data.get('records', []) response_devices = response_data.get('records', [])
devices = {d['deviceId']: d['deviceName'] for d in devices} devices.update({d['deviceName']: d['deviceName'] for d in response_devices})
devices.update({'': ''})
return devices return devices
else: else:
error_msg = response_data.get('errorMessage') error_msg = response_data.get('errorMessage')
logger.info(u"Tautulli Notifiers :: Unable to retrieve {name} devices list: {msg}".format(name=self.NAME, msg=error_msg)) logger.info(u"Tautulli Notifiers :: Unable to retrieve {name} devices list: {msg}".format(name=self.NAME, msg=error_msg))
return {'': ''} return devices
else: else:
logger.error(u"Tautulli Notifiers :: Unable to retrieve {name} devices list: [{r.status_code}] {r.reason}".format(name=self.NAME, r=r)) logger.error(u"Tautulli Notifiers :: Unable to retrieve {name} devices list: [{r.status_code}] {r.reason}".format(name=self.NAME, r=r))
logger.debug(u"Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True))) logger.debug(u"Tautulli Notifiers :: Request response: {}".format(request.server_message(r, True)))
return {'': ''} return devices
else: else:
return {'': ''} return devices
def return_config_options(self): def return_config_options(self):
devices = '<br>'.join(['%s: <span class="inline-pre">%s</span>'
% (v, k) for k, v in self.get_devices().iteritems() if k])
if not devices:
devices = 'Enter your Join API key to load your device list.'
config_option = [{'label': 'Join API Key', config_option = [{'label': 'Join API Key',
'value': self.config['api_key'], 'value': self.config['api_key'],
'name': 'join_api_key', 'name': 'join_api_key',
@@ -2023,22 +2042,55 @@ class JOIN(Notifier):
'input_type': 'text', 'input_type': 'text',
'refresh': True 'refresh': True
}, },
{'label': 'Device ID(s) or Group ID', {'label': 'Device Name(s)',
'value': self.config['device_id'], 'value': self.config['device_names'],
'name': 'join_device_id', 'name': 'join_device_names',
'description': 'Set your Join device ID or group ID. ' \ 'description': 'Select your Join device(s).',
'Separate multiple devices with commas (,).', 'input_type': 'select',
'input_type': 'text', 'select_options': self.get_devices()
}, },
{'label': 'Your Devices IDs', {'label': 'Priority',
'description': devices, 'value': self.config['priority'],
'input_type': 'help' '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', {'label': 'Include Subject Line',
'value': self.config['incl_subject'], 'value': self.config['incl_subject'],
'name': 'join_incl_subject', 'name': 'join_incl_subject',
'description': 'Include the subject line with the notifications.', 'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox' 'input_type': 'checkbox'
},
{'label': 'Include Poster Image',
'value': self.config['incl_poster'],
'name': 'join_incl_poster',
'description': 'Include a poster with the notifications.<br>'
'Imgur upload may need to be enabled under the notifications 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 on the info cards. Leave blank for default.<br>'
'3rd party API lookup may need to be enabled under the notifications 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 on the info cards. Leave blank for default.<br>'
'3rd party API lookup may need to be enabled under the notifications 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 on the info cards. Leave blank for default.',
'input_type': 'select',
'select_options': PrettyMetadata().get_music_providers()
} }
] ]
@@ -2061,10 +2113,7 @@ class MQTT(Notifier):
'keep_alive': 60 'keep_alive': 60
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
if not self.config['topic']: if not self.config['topic']:
logger.error(u"Tautulli Notifiers :: MQTT topic not specified.") logger.error(u"Tautulli Notifiers :: MQTT topic not specified.")
return return
@@ -2167,10 +2216,7 @@ class NMA(Notifier):
'priority': 0 'priority': 0
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
title = 'Tautulli' title = 'Tautulli'
batch = False batch = False
@@ -2247,7 +2293,7 @@ class OSX(Notifier):
def _swizzled_bundleIdentifier(self, original, swizzled): def _swizzled_bundleIdentifier(self, original, swizzled):
return 'ade.plexpy.osxnotify' return 'ade.plexpy.osxnotify'
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
subtitle = kwargs.get('subtitle', '') subtitle = kwargs.get('subtitle', '')
sound = kwargs.get('sound', '') sound = kwargs.get('sound', '')
@@ -2340,10 +2386,7 @@ class PLEX(Notifier):
if response: if response:
return response[0]['result'] return response[0]['result']
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
hosts = [x.strip() for x in self.config['hosts'].split(',')] hosts = [x.strip() for x in self.config['hosts'].split(',')]
if self.config['display_time'] > 0: if self.config['display_time'] > 0:
@@ -2354,7 +2397,7 @@ class PLEX(Notifier):
if self.config['image']: if self.config['image']:
image = self.config['image'] image = self.config['image']
else: else:
image = os.path.join(plexpy.DATA_DIR, os.path.abspath("data/interfaces/default/images/logo.png")) image = os.path.join(plexpy.DATA_DIR, os.path.abspath("data/interfaces/default/images/logo-circle.png"))
for host in hosts: for host in hosts:
logger.info(u"Tautulli Notifiers :: Sending notification command to {name} @ {host}".format(name=self.NAME, host=host)) logger.info(u"Tautulli Notifiers :: Sending notification command to {name} @ {host}".format(name=self.NAME, host=host))
@@ -2378,7 +2421,7 @@ class PLEX(Notifier):
except Exception as e: except Exception as e:
logger.error(u"Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e)) logger.error(u"Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e))
return False return False
return True return True
def return_config_options(self): def return_config_options(self):
@@ -2426,16 +2469,13 @@ class PROWL(Notifier):
'priority': 0 'priority': 0
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
data = {'apikey': self.config['key'], data = {'apikey': self.config['key'],
'application': 'Tautulli', 'application': 'Tautulli',
'event': subject.encode("utf-8"), 'event': subject.encode("utf-8"),
'description': body.encode("utf-8"), 'description': body.encode("utf-8"),
'priority': self.config['priority']} 'priority': self.config['priority']}
headers = {'Content-type': 'application/x-www-form-urlencoded'} headers = {'Content-type': 'application/x-www-form-urlencoded'}
return self.make_request('https://api.prowlapp.com/publicapi/add', headers=headers, data=data) return self.make_request('https://api.prowlapp.com/publicapi/add', headers=headers, data=data)
@@ -2467,10 +2507,7 @@ class PUSHALOT(Notifier):
_DEFAULT_CONFIG = {'api_key': '' _DEFAULT_CONFIG = {'api_key': ''
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
data = {'AuthorizationToken': self.config['api_key'], data = {'AuthorizationToken': self.config['api_key'],
'Title': subject.encode('utf-8'), 'Title': subject.encode('utf-8'),
'Body': body.encode("utf-8")} 'Body': body.encode("utf-8")}
@@ -2501,10 +2538,7 @@ class PUSHBULLET(Notifier):
'channel_tag': '' 'channel_tag': ''
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
data = {'type': 'note', data = {'type': 'note',
'title': subject.encode("utf-8"), 'title': subject.encode("utf-8"),
'body': body.encode("utf-8")} 'body': body.encode("utf-8")}
@@ -2586,10 +2620,7 @@ class PUSHOVER(Notifier):
'music_provider': '' 'music_provider': ''
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
data = {'token': self.config['api_token'], data = {'token': self.config['api_token'],
'user': self.config['key'], 'user': self.config['key'],
'title': subject.encode("utf-8"), 'title': subject.encode("utf-8"),
@@ -2683,16 +2714,16 @@ class PUSHOVER(Notifier):
{'label': 'Movie Link Source', {'label': 'Movie Link Source',
'value': self.config['movie_provider'], 'value': self.config['movie_provider'],
'name': 'pushover_movie_provider', 'name': 'pushover_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank for default.<br> \ 'description': 'Select the source for movie links on the info cards. Leave blank for default.<br>'
3rd party API lookup may need to be enabled under the notification settings tab.', '3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers() 'select_options': PrettyMetadata().get_movie_providers()
}, },
{'label': 'TV Show Link Source', {'label': 'TV Show Link Source',
'value': self.config['tv_provider'], 'value': self.config['tv_provider'],
'name': 'pushover_tv_provider', 'name': 'pushover_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br> \ 'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br>'
3rd party API lookup may need to be enabled under the notification settings tab.', '3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers() 'select_options': PrettyMetadata().get_tv_providers()
}, },
@@ -2794,7 +2825,7 @@ class SCRIPTS(Notifier):
logger.info(u"Tautulli Notifiers :: Script notification sent.") logger.info(u"Tautulli Notifiers :: Script notification sent.")
return True return True
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
""" """
Args: Args:
subject(string, optional): Subject text, subject(string, optional): Subject text,
@@ -2906,10 +2937,7 @@ class SLACK(Notifier):
'music_provider': '' 'music_provider': ''
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
if self.config['incl_subject']: if self.config['incl_subject']:
text = subject.encode('utf-8') + '\r\n' + body.encode("utf-8") text = subject.encode('utf-8') + '\r\n' + body.encode("utf-8")
else: else:
@@ -3021,7 +3049,8 @@ class SLACK(Notifier):
{'label': 'Include Rich Metadata Info', {'label': 'Include Rich Metadata Info',
'value': self.config['incl_card'], 'value': self.config['incl_card'],
'name': 'slack_incl_card', 'name': 'slack_incl_card',
'description': 'Include an info card with a poster and metadata with the notifications.', 'description': 'Include an info card with a poster and metadata with the notifications.<br>'
'Imgur upload may need to be enabled under the notifications settings tab.',
'input_type': 'checkbox' 'input_type': 'checkbox'
}, },
{'label': 'Include Plot Summaries', {'label': 'Include Plot Summaries',
@@ -3045,16 +3074,16 @@ class SLACK(Notifier):
{'label': 'Movie Link Source', {'label': 'Movie Link Source',
'value': self.config['movie_provider'], 'value': self.config['movie_provider'],
'name': 'slack_movie_provider', 'name': 'slack_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank for default.<br> \ 'description': 'Select the source for movie links on the info cards. Leave blank for default.<br>'
3rd party API lookup may need to be enabled under the notification settings tab.', '3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_movie_providers() 'select_options': PrettyMetadata().get_movie_providers()
}, },
{'label': 'TV Show Link Source', {'label': 'TV Show Link Source',
'value': self.config['tv_provider'], 'value': self.config['tv_provider'],
'name': 'slack_tv_provider', 'name': 'slack_tv_provider',
'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br> \ 'description': 'Select the source for tv show links on the info cards. Leave blank for default.<br>'
3rd party API lookup may need to be enabled under the notification settings tab.', '3rd party API lookup may need to be enabled under the notifications settings tab.',
'input_type': 'select', 'input_type': 'select',
'select_options': PrettyMetadata().get_tv_providers() 'select_options': PrettyMetadata().get_tv_providers()
}, },
@@ -3083,10 +3112,7 @@ class TELEGRAM(Notifier):
'incl_poster': 0 'incl_poster': 0
} }
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not body or not subject:
return
data = {'chat_id': self.config['chat_id']} data = {'chat_id': self.config['chat_id']}
if self.config['incl_subject']: if self.config['incl_subject']:
@@ -3149,7 +3175,8 @@ class TELEGRAM(Notifier):
{'label': 'Include Poster Image', {'label': 'Include Poster Image',
'value': self.config['incl_poster'], 'value': self.config['incl_poster'],
'name': 'telegram_incl_poster', 'name': 'telegram_incl_poster',
'description': 'Include a poster with the notifications.', 'description': 'Include a poster with the notifications.<br>'
'Imgur upload may need to be enabled under the notifications settings tab.',
'input_type': 'checkbox' 'input_type': 'checkbox'
}, },
{'label': 'Enable HTML Support', {'label': 'Enable HTML Support',
@@ -3204,10 +3231,7 @@ class TWITTER(Notifier):
logger.error(u"Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e)) logger.error(u"Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e))
return False return False
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
poster_url = '' poster_url = ''
if self.config['incl_poster'] and kwargs.get('parameters'): if self.config['incl_poster'] and kwargs.get('parameters'):
parameters = kwargs['parameters'] parameters = kwargs['parameters']
@@ -3220,12 +3244,12 @@ class TWITTER(Notifier):
def return_config_options(self): def return_config_options(self):
config_option = [{'label': 'Instructions', config_option = [{'label': 'Instructions',
'description': 'Step 1: Visit <a href="' + helpers.anon_url('https://apps.twitter.com') + '" target="_blank"> \ 'description': 'Step 1: Visit <a href="' + helpers.anon_url('https://apps.twitter.com') + '" target="_blank">'
Twitter Apps</a> to <strong>Create New App</strong>. A vaild "Website" is not required.<br>\ 'Twitter Apps</a> to <strong>Create New App</strong>. A vaild "Website" is not required.<br>'
Step 2: Go to <strong>Keys and Access Tokens</strong> and click \ 'Step 2: Go to <strong>Keys and Access Tokens</strong> and click '
<strong>Create my access token</strong>.<br>\ '<strong>Create my access token</strong>.<br>'
Step 3: Fill in the <strong>Consumer Key</strong>, <strong>Consumer Secret</strong>, \ 'Step 3: Fill in the <strong>Consumer Key</strong>, <strong>Consumer Secret</strong>, '
<strong>Access Token</strong>, and <strong>Access Token Secret</strong> below.', '<strong>Access Token</strong>, and <strong>Access Token Secret</strong> below.',
'input_type': 'help' 'input_type': 'help'
}, },
{'label': 'Twitter Consumer Key', {'label': 'Twitter Consumer Key',
@@ -3261,7 +3285,8 @@ class TWITTER(Notifier):
{'label': 'Include Poster Image', {'label': 'Include Poster Image',
'value': self.config['incl_poster'], 'value': self.config['incl_poster'],
'name': 'twitter_incl_poster', 'name': 'twitter_incl_poster',
'description': 'Include a poster with the notifications.', 'description': 'Include a poster with the notifications.<br>'
'Imgur upload may need to be enabled under the notifications settings tab.',
'input_type': 'checkbox' 'input_type': 'checkbox'
} }
] ]
@@ -3304,10 +3329,7 @@ class XBMC(Notifier):
if response: if response:
return response[0]['result'] return response[0]['result']
def notify(self, subject='', body='', action='', **kwargs): def agent_notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
hosts = [x.strip() for x in self.config['hosts'].split(',')] hosts = [x.strip() for x in self.config['hosts'].split(',')]
if self.config['display_time'] > 0: if self.config['display_time'] > 0:
@@ -3318,7 +3340,7 @@ class XBMC(Notifier):
if self.config['image']: if self.config['image']:
image = self.config['image'] image = self.config['image']
else: else:
image = os.path.join(plexpy.DATA_DIR, os.path.abspath("data/interfaces/default/images/logo.png")) image = os.path.join(plexpy.DATA_DIR, os.path.abspath("data/interfaces/default/images/logo-circle.png"))
for host in hosts: for host in hosts:
logger.info(u"Tautulli Notifiers :: Sending notification command to XMBC @ " + host) logger.info(u"Tautulli Notifiers :: Sending notification command to XMBC @ " + host)
@@ -3503,3 +3525,27 @@ def upgrade_config_to_db():
notifier_id = add_notifier_config(agent_id=agent_id) notifier_id = add_notifier_config(agent_id=agent_id)
set_notifier_config(notifier_id=notifier_id, agent_id=agent_id, **notifier_config) set_notifier_config(notifier_id=notifier_id, agent_id=agent_id, **notifier_config)
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}

View File

@@ -331,18 +331,19 @@ class PlexTV(object):
for a in xml_head: for a in xml_head:
own_details = {"user_id": helpers.get_xml_attr(a, 'id'), own_details = {"user_id": helpers.get_xml_attr(a, 'id'),
"username": helpers.get_xml_attr(a, 'username'), "username": helpers.get_xml_attr(a, 'username'),
"thumb": helpers.get_xml_attr(a, 'thumb'), "thumb": helpers.get_xml_attr(a, 'thumb'),
"email": helpers.get_xml_attr(a, 'email'), "email": helpers.get_xml_attr(a, 'email'),
"is_home_user": helpers.get_xml_attr(a, 'home'), "is_home_user": helpers.get_xml_attr(a, 'home'),
"is_allow_sync": None, "is_admin": 1,
"is_restricted": helpers.get_xml_attr(a, 'restricted'), "is_allow_sync": None,
"filter_all": helpers.get_xml_attr(a, 'filterAll'), "is_restricted": helpers.get_xml_attr(a, 'restricted'),
"filter_movies": helpers.get_xml_attr(a, 'filterMovies'), "filter_all": helpers.get_xml_attr(a, 'filterAll'),
"filter_tv": helpers.get_xml_attr(a, 'filterTelevision'), "filter_movies": helpers.get_xml_attr(a, 'filterMovies'),
"filter_music": helpers.get_xml_attr(a, 'filterMusic'), "filter_tv": helpers.get_xml_attr(a, 'filterTelevision'),
"filter_photos": helpers.get_xml_attr(a, 'filterPhotos') "filter_music": helpers.get_xml_attr(a, 'filterMusic'),
} "filter_photos": helpers.get_xml_attr(a, 'filterPhotos')
}
users_list.append(own_details) users_list.append(own_details)
@@ -354,18 +355,19 @@ class PlexTV(object):
for a in xml_head: for a in xml_head:
friend = {"user_id": helpers.get_xml_attr(a, 'id'), friend = {"user_id": helpers.get_xml_attr(a, 'id'),
"username": helpers.get_xml_attr(a, 'title'), "username": helpers.get_xml_attr(a, 'title'),
"thumb": helpers.get_xml_attr(a, 'thumb'), "thumb": helpers.get_xml_attr(a, 'thumb'),
"email": helpers.get_xml_attr(a, 'email'), "email": helpers.get_xml_attr(a, 'email'),
"is_home_user": helpers.get_xml_attr(a, 'home'), "is_admin": 0,
"is_allow_sync": helpers.get_xml_attr(a, 'allowSync'), "is_home_user": helpers.get_xml_attr(a, 'home'),
"is_restricted": helpers.get_xml_attr(a, 'restricted'), "is_allow_sync": helpers.get_xml_attr(a, 'allowSync'),
"filter_all": helpers.get_xml_attr(a, 'filterAll'), "is_restricted": helpers.get_xml_attr(a, 'restricted'),
"filter_movies": helpers.get_xml_attr(a, 'filterMovies'), "filter_all": helpers.get_xml_attr(a, 'filterAll'),
"filter_tv": helpers.get_xml_attr(a, 'filterTelevision'), "filter_movies": helpers.get_xml_attr(a, 'filterMovies'),
"filter_music": helpers.get_xml_attr(a, 'filterMusic'), "filter_tv": helpers.get_xml_attr(a, 'filterTelevision'),
"filter_photos": helpers.get_xml_attr(a, 'filterPhotos') "filter_music": helpers.get_xml_attr(a, 'filterMusic'),
} "filter_photos": helpers.get_xml_attr(a, 'filterPhotos')
}
users_list.append(friend) users_list.append(friend)

View File

@@ -537,7 +537,7 @@ class PmsConnect(object):
try: try:
with open(in_file_path, 'r') as inFile: with open(in_file_path, 'r') as inFile:
metadata = json.load(inFile) metadata = json.load(inFile)
except IOError as e: except (IOError, ValueError) as e:
pass pass
if metadata: if metadata:
@@ -1162,7 +1162,7 @@ class PmsConnect(object):
try: try:
with open(out_file_path, 'w') as outFile: with open(out_file_path, 'w') as outFile:
json.dump(metadata, outFile) json.dump(metadata, outFile)
except IOError as e: except (IOError, ValueError) as e:
logger.error(u"Tautulli Pmsconnect :: Unable to create cache file for metadata (sessionKey %s): %s" logger.error(u"Tautulli Pmsconnect :: Unable to create cache file for metadata (sessionKey %s): %s"
% (cache_key, e)) % (cache_key, e))

View File

@@ -23,16 +23,15 @@ def get_session_info():
""" """
Returns the session info for the user session Returns the session info for the user session
""" """
from plexpy.webauth import SESSION_KEY
_session = {'user_id': None, _session = {'user_id': None,
'user': None, 'user': None,
'user_group': 'admin', 'user_group': 'admin',
'expiry': None} 'exp': None}
try:
return cherrypy.session.get(SESSION_KEY, _session) if isinstance(cherrypy.request.login, dict):
except AttributeError as e: return cherrypy.request.login
return _session
return _session
def get_session_user(): def get_session_user():
""" """

View File

@@ -52,6 +52,7 @@ def refresh_users():
new_value_dict = {"username": item['username'], new_value_dict = {"username": item['username'],
"thumb": item['thumb'], "thumb": item['thumb'],
"email": item['email'], "email": item['email'],
"is_admin": item['is_admin'],
"is_home_user": item['is_home_user'], "is_home_user": item['is_home_user'],
"is_allow_sync": item['is_allow_sync'], "is_allow_sync": item['is_allow_sync'],
"is_restricted": item['is_restricted'], "is_restricted": item['is_restricted'],
@@ -330,6 +331,7 @@ class Users(object):
'friendly_name': 'Local', 'friendly_name': 'Local',
'user_thumb': common.DEFAULT_USER_THUMB, 'user_thumb': common.DEFAULT_USER_THUMB,
'email': '', 'email': '',
'is_admin': '',
'is_home_user': 0, 'is_home_user': 0,
'is_allow_sync': 0, 'is_allow_sync': 0,
'is_restricted': 0, 'is_restricted': 0,
@@ -349,21 +351,21 @@ class Users(object):
try: try:
if str(user_id).isdigit(): if str(user_id).isdigit():
query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \
'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \ 'email, is_admin, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \
'allow_guest, shared_libraries ' \ 'allow_guest, shared_libraries ' \
'FROM users ' \ 'FROM users ' \
'WHERE user_id = ? ' 'WHERE user_id = ? '
result = monitor_db.select(query, args=[user_id]) result = monitor_db.select(query, args=[user_id])
elif user: elif user:
query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \
'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \ 'email, is_admin, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \
'allow_guest, shared_libraries ' \ 'allow_guest, shared_libraries ' \
'FROM users ' \ 'FROM users ' \
'WHERE username = ? COLLATE NOCASE ' 'WHERE username = ? COLLATE NOCASE '
result = monitor_db.select(query, args=[user]) result = monitor_db.select(query, args=[user])
elif email: elif email:
query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \ query = 'SELECT user_id, username, friendly_name, thumb AS user_thumb, custom_avatar_url AS custom_thumb, ' \
'email, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \ 'email, is_admin, is_home_user, is_allow_sync, is_restricted, do_notify, keep_history, deleted_user, ' \
'allow_guest, shared_libraries ' \ 'allow_guest, shared_libraries ' \
'FROM users ' \ 'FROM users ' \
'WHERE email = ? COLLATE NOCASE ' 'WHERE email = ? COLLATE NOCASE '
@@ -398,6 +400,7 @@ class Users(object):
'friendly_name': friendly_name, 'friendly_name': friendly_name,
'user_thumb': user_thumb, 'user_thumb': user_thumb,
'email': item['email'], 'email': item['email'],
'is_admin': item['is_admin'],
'is_home_user': item['is_home_user'], 'is_home_user': item['is_home_user'],
'is_allow_sync': item['is_allow_sync'], 'is_allow_sync': item['is_allow_sync'],
'is_restricted': item['is_restricted'], 'is_restricted': item['is_restricted'],
@@ -580,6 +583,27 @@ class Users(object):
return recently_watched return recently_watched
def get_users(self):
monitor_db = database.MonitorDatabase()
try:
query = 'SELECT user_id, username, friendly_name, email FROM users WHERE deleted_user = 0'
result = monitor_db.select(query=query)
except Exception as e:
logger.warn(u"Tautulli Users :: Unable to execute database query for get_users: %s." % e)
return None
users = []
for item in result:
user = {'user_id': item['user_id'],
'username': item['username'],
'friendly_name': item['friendly_name'] or item['username'],
'email': item['email']
}
users.append(user)
return users
def delete_all_history(self, user_id=None): def delete_all_history(self, user_id=None):
monitor_db = database.MonitorDatabase() monitor_db = database.MonitorDatabase()

View File

@@ -1,2 +1,2 @@
PLEXPY_BRANCH = "beta" PLEXPY_BRANCH = "beta"
PLEXPY_RELEASE_VERSION = "v2.0.12-beta" PLEXPY_RELEASE_VERSION = "v2.0.14-beta"

View File

@@ -298,14 +298,14 @@ def checkout_git_branch():
logger.info('Output: ' + str(output)) logger.info('Output: ' + str(output))
def read_changelog(latest_only=False): def read_changelog(latest_only=False, since_prev_release=False):
changelog_file = os.path.join(plexpy.PROG_DIR, 'CHANGELOG.md') changelog_file = os.path.join(plexpy.PROG_DIR, 'CHANGELOG.md')
if not os.path.isfile(changelog_file): if not os.path.isfile(changelog_file):
return '<h4>Missing changelog file</h4>' return '<h4>Missing changelog file</h4>'
try: try:
output = '' output = ['']
prev_level = 0 prev_level = 0
latest_version_found = False latest_version_found = False
@@ -329,27 +329,34 @@ def read_changelog(latest_only=False):
break break
elif latest_only: elif latest_only:
latest_version_found = True latest_version_found = True
# Add a space to the end of the release to match tags
elif since_prev_release and str(plexpy.PREV_RELEASE) + ' ' in header_text:
break
output += '<h' + header_level + '>' + header_text + '</h' + header_level + '>' output[-1] += '<h' + header_level + '>' + header_text + '</h' + header_level + '>'
elif line_list_match: elif line_list_match:
line_level = len(line_list_match.group(1)) / 2 line_level = len(line_list_match.group(1)) / 2
line_text = line_list_match.group(2) line_text = line_list_match.group(2)
if line_level > prev_level: if line_level > prev_level:
output += '<ul>' * (line_level - prev_level) + '<li>' + line_text + '</li>' output[-1] += '<ul>' * (line_level - prev_level) + '<li>' + line_text + '</li>'
elif line_level < prev_level: elif line_level < prev_level:
output += '</ul>' * (prev_level - line_level) + '<li>' + line_text + '</li>' output[-1] += '</ul>' * (prev_level - line_level) + '<li>' + line_text + '</li>'
else: else:
output += '<li>' + line_text + '</li>' output[-1] += '<li>' + line_text + '</li>'
prev_level = line_level prev_level = line_level
elif line.strip() == '' and prev_level: elif line.strip() == '' and prev_level:
output += '</ul>' * (prev_level) output[-1] += '</ul>' * (prev_level)
output.append('')
prev_level = 0 prev_level = 0
return output if since_prev_release:
output.reverse()
return ''.join(output)
except IOError as e: except IOError as e:
logger.error('Tautulli Version Checker :: Unable to open changelog file. %s' % e) logger.error('Tautulli Version Checker :: Unable to open changelog file. %s' % e)

View File

@@ -180,7 +180,7 @@ def process(opcode, data):
info = json.loads(data) info = json.loads(data)
except Exception as e: except Exception as e:
logger.warn(u"Tautulli WebSocket :: Error decoding message from websocket: %s" % e) logger.warn(u"Tautulli WebSocket :: Error decoding message from websocket: %s" % e)
logger.debug(data) logger.websocket_error(data)
return False return False
info = info.get('NotificationContainer', info) info = info.get('NotificationContainer', info)

View File

@@ -18,12 +18,12 @@
# Form based authentication for CherryPy. Requires the # Form based authentication for CherryPy. Requires the
# Session tool to be loaded. # Session tool to be loaded.
from cgi import escape
from datetime import datetime, timedelta from datetime import datetime, timedelta
import re import re
import cherrypy import cherrypy
from hashing_passwords import check_hash from hashing_passwords import check_hash
import jwt
import plexpy import plexpy
import logger import logger
@@ -32,7 +32,9 @@ from plexpy.users import Users, refresh_users
from plexpy.plextv import PlexTV from plexpy.plextv import PlexTV
SESSION_KEY = '_cp_username' JWT_ALGORITHM = 'HS256'
JWT_COOKIE_NAME = 'tautulli_token_'
def user_login(username=None, password=None): def user_login(username=None, password=None):
if not username or not password: if not username or not password:
@@ -52,10 +54,17 @@ def user_login(username=None, password=None):
if user_id != str(user_details['user_id']): if user_id != str(user_details['user_id']):
# The user is not in the database. # The user is not in the database.
return None return None
elif plexpy.CONFIG.HTTP_PLEX_ADMIN and user_details['is_admin']:
# Plex admin login
return 'admin'
elif not user_details['allow_guest'] or user_details['deleted_user']: elif not user_details['allow_guest'] or user_details['deleted_user']:
# Guest access is disabled or the user is deleted. # Guest access is disabled or the user is deleted.
return None return None
# Stop here if guest access is not enabled
if not plexpy.CONFIG.ALLOW_GUEST_ACCESS:
return None
# The user is in the database, and guest access is enabled, so try to retrieve a server token. # The user is in the database, and guest access is enabled, so try to retrieve a server token.
# If a server token is returned, then the user is a valid friend of the server. # If a server token is returned, then the user is a valid friend of the server.
plex_tv = PlexTV(token=user_token) plex_tv = PlexTV(token=user_token)
@@ -73,7 +82,7 @@ def user_login(username=None, password=None):
# Refresh the users list to make sure we have all the correct permissions. # Refresh the users list to make sure we have all the correct permissions.
refresh_users() refresh_users()
# Successful login # Successful login
return True return 'guest'
else: else:
logger.warn(u"Tautulli WebAuth :: Unable to register user '%s' in database." % username) logger.warn(u"Tautulli WebAuth :: Unable to register user '%s' in database." % username)
return None return None
@@ -89,38 +98,62 @@ def user_login(username=None, password=None):
return None return None
def check_credentials(username, password, admin_login='0'): def check_credentials(username, password, admin_login='0'):
"""Verifies credentials for username and password. """Verifies credentials for username and password.
Returns True and the user group on success or False and no user group""" Returns True and the user group on success or False and no user group"""
if plexpy.CONFIG.HTTP_HASHED_PASSWORD and \ if plexpy.CONFIG.HTTP_PASSWORD:
username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD): if plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
return True, u'admin' username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD):
elif not plexpy.CONFIG.HTTP_HASHED_PASSWORD and \ return True, 'admin'
username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD: elif not plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
return True, u'admin' username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD:
elif not admin_login == '1' and plexpy.CONFIG.ALLOW_GUEST_ACCESS and user_login(username, password): return True, 'admin'
return True, u'guest'
else: if plexpy.CONFIG.HTTP_PLEX_ADMIN or (not admin_login == '1' and plexpy.CONFIG.ALLOW_GUEST_ACCESS):
return False, None plex_login = user_login(username, password)
if plex_login is not None:
return True, plex_login
return False, None
def check_jwt_token():
jwt_cookie = JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID
jwt_token = cherrypy.request.cookie.get(jwt_cookie)
if jwt_token:
try:
payload = jwt.decode(
jwt_token.value, plexpy.CONFIG.JWT_SECRET, leeway=timedelta(seconds=10), algorithms=[JWT_ALGORITHM]
)
except (jwt.DecodeError, jwt.ExpiredSignatureError):
return None
return payload
def check_auth(*args, **kwargs): def check_auth(*args, **kwargs):
"""A tool that looks in config for 'auth.require'. If found and it """A tool that looks in config for 'auth.require'. If found and it
is not None, a login is required and the entry is evaluated as a list of is not None, a login is required and the entry is evaluated as a list of
conditions that the user must fulfill""" conditions that the user must fulfill"""
conditions = cherrypy.request.config.get('auth.require', None) conditions = cherrypy.request.config.get('auth.require', None)
if conditions is not None: if conditions is not None:
_session = cherrypy.session.get(SESSION_KEY) payload = check_jwt_token()
if payload:
cherrypy.request.login = payload
if _session and (_session['user'] and _session['expiry']) and _session['expiry'] > datetime.now():
cherrypy.request.login = _session['user']
for condition in conditions: for condition in conditions:
# A condition is just a callable that returns true or false # A condition is just a callable that returns true or false
if not condition(): if not condition():
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
else: else:
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/logout") raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/logout")
def requireAuth(*conditions): def requireAuth(*conditions):
"""A decorator that appends conditions to the auth.require config """A decorator that appends conditions to the auth.require config
variable.""" variable."""
@@ -141,14 +174,13 @@ def requireAuth(*conditions):
# #
# Define those at will however suits the application. # Define those at will however suits the application.
def member_of(groupname): def member_of(user_group):
def check(): return lambda: cherrypy.request.login and cherrypy.request.login['user_group'] == user_group
# replace with actual check if <username> is in <groupname>
return cherrypy.request.login == plexpy.CONFIG.HTTP_USERNAME and groupname == 'admin'
return check def name_is(user_name):
return lambda: cherrypy.request.login and cherrypy.request.login['user'] == user_name
def name_is(reqd_username):
return lambda: reqd_username == cherrypy.request.login
# These might be handy # These might be handy
@@ -161,6 +193,7 @@ def any_of(*conditions):
return False return False
return check return check
# By default all conditions are required, but this might still be # By default all conditions are required, but this might still be
# needed if you want to use it inside of an any_of(...) condition # needed if you want to use it inside of an any_of(...) condition
def all_of(*conditions): def all_of(*conditions):
@@ -176,7 +209,12 @@ def all_of(*conditions):
# Controller to provide login and logout actions # Controller to provide login and logout actions
class AuthController(object): class AuthController(object):
def check_auth_enabled(self):
if not plexpy.CONFIG.HTTP_BASIC_AUTH and plexpy.CONFIG.HTTP_PASSWORD:
return
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
def on_login(self, user_id, username, user_group): def on_login(self, user_id, username, user_group):
"""Called on successful login""" """Called on successful login"""
@@ -197,7 +235,7 @@ class AuthController(object):
def on_logout(self, username, user_group): def on_logout(self, username, user_group):
"""Called on logout""" """Called on logout"""
logger.debug(u"Tautulli WebAuth :: %s User '%s' logged out of Tautulli." % (user_group.capitalize(), username)) logger.debug(u"Tautulli WebAuth :: %s user '%s' logged out of Tautulli." % (user_group.capitalize(), username))
def on_login_failed(self, username): def on_login_failed(self, username):
"""Called on failed login""" """Called on failed login"""
@@ -213,25 +251,48 @@ class AuthController(object):
user_agent=user_agent, user_agent=user_agent,
success=0) success=0)
def get_loginform(self, username="", msg=""): def get_loginform(self):
from plexpy.webserve import serve_template from plexpy.webserve import serve_template
return serve_template(templatename="login.html", title="Login", username=escape(username, True), msg=msg) return serve_template(templatename="login.html", title="Login")
@cherrypy.expose @cherrypy.expose
def index(self): def index(self):
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login") raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login")
@cherrypy.expose @cherrypy.expose
def login(self, username=None, password=None, remember_me='0', admin_login='0'): def login(self):
if not cherrypy.config.get('tools.sessions.on'): self.check_auth_enabled()
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
if not username and not password: return self.get_loginform()
return self.get_loginform()
(vaild_login, user_group) = check_credentials(username, password, admin_login)
if vaild_login: @cherrypy.expose
def logout(self):
self.check_auth_enabled()
payload = check_jwt_token()
if payload:
self.on_logout(payload['user'], payload['user_group'])
jwt_cookie = JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID
cherrypy.response.cookie[jwt_cookie] = 'expire'
cherrypy.response.cookie[jwt_cookie]['expires'] = 0
cherrypy.response.cookie[jwt_cookie]['path'] = '/'
cherrypy.request.login = None
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login")
@cherrypy.expose
@cherrypy.tools.json_out()
def signin(self, username=None, password=None, remember_me='0', admin_login='0'):
if cherrypy.request.method != 'POST':
cherrypy.response.status = 405
return {'status': 'error', 'message': 'Sign in using POST.'}
error_message = {'status': 'error', 'message': 'Incorrect username or password.'}
valid_login, user_group = check_credentials(username, password, admin_login)
if valid_login:
if user_group == 'guest': if user_group == 'guest':
if re.match(r"[^@]+@[^@]+\.[^@]+", username): if re.match(r"[^@]+@[^@]+\.[^@]+", username):
user_details = Users().get_details(email=username) user_details = Users().get_details(email=username)
@@ -242,35 +303,37 @@ class AuthController(object):
else: else:
user_id = None user_id = None
expiry = datetime.now() + (timedelta(days=30) if remember_me == '1' else timedelta(minutes=60)) time_delta = timedelta(days=30) if remember_me == '1' else timedelta(minutes=60)
expiry = datetime.utcnow() + time_delta
cherrypy.request.login = username payload = {
cherrypy.session[SESSION_KEY] = {'user_id': user_id, 'user_id': user_id,
'user': username, 'user': username,
'user_group': user_group, 'user_group': user_group,
'expiry': expiry} 'exp': expiry
}
jwt_token = jwt.encode(payload, plexpy.CONFIG.JWT_SECRET, algorithm=JWT_ALGORITHM)
self.on_login(user_id, username, user_group) self.on_login(user_id, username, user_group)
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
jwt_cookie = JWT_COOKIE_NAME + plexpy.CONFIG.PMS_UUID
cherrypy.response.cookie[jwt_cookie] = jwt_token
cherrypy.response.cookie[jwt_cookie]['expires'] = int(time_delta.total_seconds())
cherrypy.response.cookie[jwt_cookie]['path'] = '/'
cherrypy.request.login = payload
cherrypy.response.status = 200
return {'status': 'success', 'token': jwt_token.decode('utf-8'), 'uuid': plexpy.CONFIG.PMS_UUID}
elif admin_login == '1': elif admin_login == '1':
self.on_login_failed(username) self.on_login_failed(username)
logger.debug(u"Tautulli WebAuth :: Invalid admin login attempt from '%s'." % username) logger.debug(u"Tautulli WebAuth :: Invalid admin login attempt from '%s'." % username)
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) cherrypy.response.status = 401
return error_message
else: else:
self.on_login_failed(username) self.on_login_failed(username)
logger.debug(u"Tautulli WebAuth :: Invalid login attempt from '%s'." % username) logger.debug(u"Tautulli WebAuth :: Invalid login attempt from '%s'." % username)
return self.get_loginform(username, u"Incorrect username/email or password.") cherrypy.response.status = 401
return error_message
@cherrypy.expose
def logout(self):
if not cherrypy.config.get('tools.sessions.on'):
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
_session = cherrypy.session.get(SESSION_KEY)
cherrypy.session[SESSION_KEY] = None
if _session and _session['user']:
cherrypy.request.login = None
self.on_logout(_session['user'], _session['user_group'])
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login")

View File

@@ -2538,6 +2538,7 @@ class WebInterface(object):
"http_password": http_password, "http_password": http_password,
"http_root": plexpy.CONFIG.HTTP_ROOT, "http_root": plexpy.CONFIG.HTTP_ROOT,
"http_proxy": checked(plexpy.CONFIG.HTTP_PROXY), "http_proxy": checked(plexpy.CONFIG.HTTP_PROXY),
"http_plex_admin": checked(plexpy.CONFIG.HTTP_PLEX_ADMIN),
"launch_browser": checked(plexpy.CONFIG.LAUNCH_BROWSER), "launch_browser": checked(plexpy.CONFIG.LAUNCH_BROWSER),
"enable_https": checked(plexpy.CONFIG.ENABLE_HTTPS), "enable_https": checked(plexpy.CONFIG.ENABLE_HTTPS),
"https_create_cert": checked(plexpy.CONFIG.HTTPS_CREATE_CERT), "https_create_cert": checked(plexpy.CONFIG.HTTPS_CREATE_CERT),
@@ -2632,7 +2633,7 @@ class WebInterface(object):
"monitor_pms_updates", "monitor_remote_access", "get_file_sizes", "log_blacklist", "http_hash_password", "monitor_pms_updates", "monitor_remote_access", "get_file_sizes", "log_blacklist", "http_hash_password",
"allow_guest_access", "cache_images", "http_proxy", "http_basic_auth", "notify_concurrent_by_ip", "allow_guest_access", "cache_images", "http_proxy", "http_basic_auth", "notify_concurrent_by_ip",
"history_table_activity", "plexpy_auto_update", "history_table_activity", "plexpy_auto_update",
"themoviedb_lookup", "tvmaze_lookup" "themoviedb_lookup", "tvmaze_lookup", "http_plex_admin"
] ]
for checked_config in checked_configs: for checked_config in checked_configs:
if checked_config not in kwargs: if checked_config not in kwargs:
@@ -2673,8 +2674,7 @@ class WebInterface(object):
refresh_users = False refresh_users = False
# First run from the setup wizard # First run from the setup wizard
if kwargs.get('first_run'): if kwargs.pop('first_run', None):
del kwargs['first_run']
first_run = True first_run = True
# If we change any monitoring settings, make sure we reschedule tasks. # If we change any monitoring settings, make sure we reschedule tasks.
@@ -2728,12 +2728,15 @@ class WebInterface(object):
refresh_libraries = True refresh_libraries = True
# If we change the server, make sure we grab the new url and refresh libraries and users lists. # If we change the server, make sure we grab the new url and refresh libraries and users lists.
if kwargs.get('server_changed'): if kwargs.pop('server_changed', None):
del kwargs['server_changed']
server_changed = True server_changed = True
refresh_users = True refresh_users = True
refresh_libraries = True refresh_libraries = True
# If we change the authentication settings, make sure we refresh the users lists.
if kwargs.pop('auth_changed', None):
refresh_users = True
plexpy.CONFIG.process_kwargs(kwargs) plexpy.CONFIG.process_kwargs(kwargs)
# Write the config # Write the config
@@ -3123,8 +3126,7 @@ class WebInterface(object):
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
def get_browser_notifications(self, **kwargs): def get_browser_notifications(self, **kwargs):
browser = notifiers.BROWSER() result = notifiers.get_browser_notifications()
result = browser.get_notifications()
if result: if result:
notifications = result['notifications'] notifications = result['notifications']
@@ -3543,13 +3545,20 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
def get_changelog(self, latest_only=False, update_shown=False, **kwargs): def get_changelog(self, latest_only=False, since_prev_release=False, update_shown=False, **kwargs):
latest_only = True if latest_only == 'true' else False latest_only = (latest_only == 'true')
since_prev_release = (since_prev_release == 'true')
if since_prev_release and plexpy.PREV_RELEASE == common.VERSION_NUMBER:
latest_only = True
since_prev_release = False
# Set update changelog shown status # Set update changelog shown status
if update_shown == 'true': if update_shown == 'true':
plexpy.CONFIG.__setattr__('UPDATE_SHOW_CHANGELOG', 0) plexpy.CONFIG.__setattr__('UPDATE_SHOW_CHANGELOG', 0)
plexpy.CONFIG.write() plexpy.CONFIG.write()
return versioncheck.read_changelog(latest_only=latest_only)
return versioncheck.read_changelog(latest_only=latest_only, since_prev_release=since_prev_release)
##### Info ##### ##### Info #####
@@ -4454,7 +4463,7 @@ class WebInterface(object):
counts['total_bandwidth'] += helpers.cast_to_int(s['bandwidth']) counts['total_bandwidth'] += helpers.cast_to_int(s['bandwidth'])
if s['location'] == 'lan': if s['location'] == 'lan':
counts['lan_bandwidth'] += helpers.cast_to_int(s['bandwidth']) counts['lan_bandwidth'] += helpers.cast_to_int(s['bandwidth'])
elif s['location'] == 'wan': else:
counts['wan_bandwidth'] += helpers.cast_to_int(s['bandwidth']) counts['wan_bandwidth'] += helpers.cast_to_int(s['bandwidth'])
result.update(counts) result.update(counts)

View File

@@ -35,7 +35,8 @@ def initialize(options):
if enable_https: if enable_https:
# If either the HTTPS certificate or key do not exist, try to make self-signed ones. # If either the HTTPS certificate or key do not exist, try to make self-signed ones.
if plexpy.CONFIG.HTTPS_CREATE_CERT and \ if plexpy.CONFIG.HTTPS_CREATE_CERT and \
(not (https_cert and os.path.exists(https_cert)) or not (https_key and os.path.exists(https_key))): (not (https_cert and os.path.exists(https_cert)) or
not (https_key and os.path.exists(https_key))):
if not create_https_certificates(https_cert, https_key): if not create_https_certificates(https_cert, https_key):
logger.warn(u"Tautulli WebStart :: Unable to create certificate and key. Disabling HTTPS") logger.warn(u"Tautulli WebStart :: Unable to create certificate and key. Disabling HTTPS")
enable_https = False enable_https = False
@@ -67,16 +68,21 @@ def initialize(options):
protocol = "http" protocol = "http"
if options['http_password']: if options['http_password']:
logger.info(u"Tautulli WebStart :: Web server authentication is enabled, username is '%s'", options['http_username']) login_allowed = ["Tautulli admin (username is '%s')" % options['http_username']]
if plexpy.CONFIG.HTTP_PLEX_ADMIN:
login_allowed.append("Plex admin")
logger.info(u"Tautulli WebStart :: Web server authentication is enabled: %s allowed", ' and '.join(login_allowed))
if options['http_basic_auth']: if options['http_basic_auth']:
session_enabled = auth_enabled = False auth_enabled = False
basic_auth_enabled = True basic_auth_enabled = True
else: else:
options_dict['tools.sessions.on'] = session_enabled = auth_enabled = True auth_enabled = True
basic_auth_enabled = False basic_auth_enabled = False
cherrypy.tools.auth = cherrypy.Tool('before_handler', webauth.check_auth) cherrypy.tools.auth = cherrypy.Tool('before_handler', webauth.check_auth)
else: else:
session_enabled = auth_enabled = basic_auth_enabled = False auth_enabled = basic_auth_enabled = False
if options['http_root'].strip('/'): if options['http_root'].strip('/'):
plexpy.HTTP_ROOT = options['http_root'] = '/' + options['http_root'].strip('/') + '/' plexpy.HTTP_ROOT = options['http_root'] = '/' + options['http_root'].strip('/') + '/'
@@ -93,11 +99,6 @@ def initialize(options):
'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/css', 'tools.gzip.mime_types': ['text/html', 'text/plain', 'text/css',
'text/javascript', 'application/json', 'text/javascript', 'application/json',
'application/javascript'], 'application/javascript'],
'tools.sessions.on': session_enabled,
'tools.session.name': 'tautulli_session_id-' + plexpy.CONFIG.PMS_UUID,
'tools.sessions.storage_type': 'file',
'tools.sessions.storage_path': plexpy.CONFIG.CACHE_DIR,
'tools.sessions.timeout': 30 * 24 * 60, # 30 days
'tools.auth.on': auth_enabled, 'tools.auth.on': auth_enabled,
'tools.auth_basic.on': basic_auth_enabled, 'tools.auth_basic.on': basic_auth_enabled,
'tools.auth_basic.realm': 'Tautulli web server', 'tools.auth_basic.realm': 'Tautulli web server',