Compare commits
35 Commits
v2.0.12-be
...
v2.0.14-be
Author | SHA1 | Date | |
---|---|---|---|
![]() |
45c2ccdffe | ||
![]() |
fc14c3165f | ||
![]() |
0fad245148 | ||
![]() |
79609c384e | ||
![]() |
09054ddb4b | ||
![]() |
6f912d4aa2 | ||
![]() |
96033a8214 | ||
![]() |
5ca65f4797 | ||
![]() |
d2fccbde68 | ||
![]() |
e6b48d7baf | ||
![]() |
3e51310511 | ||
![]() |
32b43202c2 | ||
![]() |
446170f8de | ||
![]() |
c5a9ecd4ac | ||
![]() |
2af5f817a3 | ||
![]() |
4e55cf3cd4 | ||
![]() |
eeb0478813 | ||
![]() |
33739f1cb2 | ||
![]() |
515e6a8071 | ||
![]() |
2b22f8eb4f | ||
![]() |
e9725a0081 | ||
![]() |
8fd159d2fe | ||
![]() |
3d7e6c8b2c | ||
![]() |
0c048d61b1 | ||
![]() |
f05b8e5cd1 | ||
![]() |
0b38fec827 | ||
![]() |
547dc9ed33 | ||
![]() |
896a37bea9 | ||
![]() |
3f90037db3 | ||
![]() |
380ca11ced | ||
![]() |
ab3a288e49 | ||
![]() |
638e225f80 | ||
![]() |
5089ede207 | ||
![]() |
a3e6e76158 | ||
![]() |
7c4c7bfc90 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -12,6 +12,7 @@
|
|||||||
*.db*
|
*.db*
|
||||||
*.db-journal
|
*.db-journal
|
||||||
*.ini
|
*.ini
|
||||||
|
release.lock
|
||||||
version.lock
|
version.lock
|
||||||
logs/*
|
logs/*
|
||||||
cache/*
|
cache/*
|
||||||
|
28
CHANGELOG.md
28
CHANGELOG.md
@@ -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:
|
||||||
|
@@ -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> 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> 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> 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> Sign In');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
% endif
|
% endif
|
||||||
</script>
|
</script>
|
||||||
${next.javascriptIncludes()}
|
${next.javascriptIncludes()}
|
||||||
|
@@ -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;
|
||||||
|
@@ -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()} → ${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'])} → ${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()} → ${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()} → ${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 |
@@ -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() + ' → ' + 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 + ' → ' + 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]) + ' → ' + 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() + ' → ' + 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,
|
||||||
|
@@ -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 () {
|
||||||
|
@@ -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> Sign In</button>
|
<button id="sign-in" type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i> 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> 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> Sign In');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
@@ -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({
|
||||||
|
@@ -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;
|
||||||
|
@@ -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
29
lib/jwt/__init__.py
Normal 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
135
lib/jwt/__main__.py
Normal 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
290
lib/jwt/algorithms.py
Normal 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
189
lib/jwt/api_jws.py
Normal 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
187
lib/jwt/api_jwt.py
Normal 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
52
lib/jwt/compat.py
Normal 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
|
0
lib/jwt/contrib/__init__.py
Normal file
0
lib/jwt/contrib/__init__.py
Normal file
0
lib/jwt/contrib/algorithms/__init__.py
Normal file
0
lib/jwt/contrib/algorithms/__init__.py
Normal file
60
lib/jwt/contrib/algorithms/py_ecdsa.py
Normal file
60
lib/jwt/contrib/algorithms/py_ecdsa.py
Normal 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
|
47
lib/jwt/contrib/algorithms/pycrypto.py
Normal file
47
lib/jwt/contrib/algorithms/pycrypto.py
Normal 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
48
lib/jwt/exceptions.py
Normal 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
67
lib/jwt/utils.py
Normal 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)
|
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -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:
|
||||||
|
@@ -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:
|
||||||
|
@@ -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():
|
||||||
|
@@ -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.'},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
@@ -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']
|
||||||
|
@@ -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
|
||||||
|
@@ -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'
|
||||||
|
|
||||||
|
@@ -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 (<br>) 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}
|
||||||
|
@@ -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)
|
||||||
|
|
||||||
|
@@ -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))
|
||||||
|
|
||||||
|
@@ -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():
|
||||||
"""
|
"""
|
||||||
|
@@ -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()
|
||||||
|
|
||||||
|
@@ -1,2 +1,2 @@
|
|||||||
PLEXPY_BRANCH = "beta"
|
PLEXPY_BRANCH = "beta"
|
||||||
PLEXPY_RELEASE_VERSION = "v2.0.12-beta"
|
PLEXPY_RELEASE_VERSION = "v2.0.14-beta"
|
||||||
|
@@ -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)
|
||||||
|
@@ -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)
|
||||||
|
@@ -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")
|
|
||||||
|
@@ -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)
|
||||||
|
@@ -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',
|
||||||
|
Reference in New Issue
Block a user