Compare commits

...

41 Commits

Author SHA1 Message Date
JonnyWong16
e9725a0081 v2.0.13-beta 2018-01-13 12:34:57 -08:00
JonnyWong16
8fd159d2fe Use added_at to check recently added more than 24 hours ago 2018-01-12 12:34:07 -08:00
JonnyWong16
3d7e6c8b2c Update circle logo 2018-01-12 09:01:48 -08:00
JonnyWong16
0c048d61b1 Add message that Imgur upload may be required 2018-01-12 01:00:58 -08:00
JonnyWong16
f05b8e5cd1 Add error message when notification subject/body is required 2018-01-12 00:48:53 -08:00
JonnyWong16
0b38fec827 Add more options for Join notifications 2018-01-12 00:35:37 -08:00
JonnyWong16
547dc9ed33 Fix checking login permissions 2018-01-11 23:28:10 -08:00
JonnyWong16
896a37bea9 Allow Plex admin to login as Tautulli admin 2018-01-11 22:58:30 -08:00
JonnyWong16
3f90037db3 Make custom conditions message clearer 2018-01-11 22:52:52 -08:00
JonnyWong16
380ca11ced Fix some parameter types 2018-01-11 22:07:29 -08:00
JonnyWong16
ab3a288e49 Add "or" between condition values 2018-01-10 18:31:14 -08:00
JonnyWong16
638e225f80 Add selectize resources 2018-01-10 00:36:01 -08:00
JonnyWong16
5089ede207 Add selectize for email input 2018-01-10 00:32:24 -08:00
JonnyWong16
a3e6e76158 Implement JWT instead of using cherrypy sessions 2018-01-08 22:25:52 -08:00
JonnyWong16
7c4c7bfc90 Add pyjwt 1.4.0 2018-01-08 22:24:36 -08:00
JonnyWong16
644fea6665 v2.0.12-beta 2018-01-07 23:44:18 -08:00
JonnyWong16
a1349ff8a6 Add css for selectize to match input boxes 2018-01-07 23:37:18 -08:00
JonnyWong16
71c20002b8 Update build notify text error message 2018-01-07 18:45:55 -08:00
JonnyWong16
157af84226 Only update the database sessions every 60 seconds while playing 2018-01-07 17:10:33 -08:00
JonnyWong16
9b4536f132 Move webserver notify to API 2018-01-07 14:46:01 -08:00
JonnyWong16
29ab470e42 Make metadata cache an advanced config option 2018-01-07 10:01:17 -08:00
JonnyWong16
c67fa480a7 Make condition logic optional
* Implicit "and" between all conditions if logic is blank
2018-01-07 09:42:57 -08:00
JonnyWong16
0a1a691c73 Fix Plex URL notification parameter 2018-01-07 08:28:06 -08:00
JonnyWong16
48588f23bf Add LAN/WAN bandwidth to activity header 2018-01-06 23:06:21 -08:00
JonnyWong16
cf14fbc3f0 v2.0.11-beta 2018-01-05 21:50:35 -08:00
JonnyWong16
e471d5207d Remove experimental tag from calculate file sizes 2018-01-05 21:50:24 -08:00
JonnyWong16
5722a52082 Fix None values in stream data for pre v2 history 2018-01-05 21:37:54 -08:00
JonnyWong16
08c32e875e Fix login using hashed password 2018-01-05 21:01:32 -08:00
JonnyWong16
7d3ee3afb3 Fix recently added show title 2018-01-05 21:01:10 -08:00
JonnyWong16
def8600f5c Reload notify params from raw stream info 2018-01-05 14:22:20 -08:00
JonnyWong16
74a68f3c7d v2.0.10-beta 2018-01-04 19:55:13 -08:00
JonnyWong16
64c9247dd1 Remove library/user notification toggles
* Filter out notifications using custom conditions
2018-01-04 19:39:16 -08:00
JonnyWong16
1bfcd34247 Some formatting for common.py 2018-01-04 13:40:34 -08:00
JonnyWong16
19864e97e6 Fix media type in collection header 2018-01-04 13:40:34 -08:00
JonnyWong16
ec5c5e1420 Merge pull request #1195 from Tommatheussen/patch-1
Update date formats
2018-01-04 13:39:25 -08:00
Tom Matheussen
803f4e14ca Added some additional formats 2018-01-04 22:18:40 +01:00
Tom Matheussen
6cc254b80a Update Date Formats
Added correct Year date formats, replaced generic numeric values with actual examples
2018-01-04 21:32:59 +01:00
JonnyWong16
59593ab1aa Fix HW indicator on activity refresh 2018-01-03 20:29:52 -08:00
JonnyWong16
65a0a0eb7d v2.0.9-beta 2018-01-03 19:37:12 -08:00
JonnyWong16
f4206b401f Fix season/episode numbers zfill 2018-01-03 19:24:19 -08:00
JonnyWong16
99f8d24b3e Remove bottom padding on stats info 2018-01-03 16:35:22 -08:00
44 changed files with 2227 additions and 637 deletions

View File

@@ -1,10 +1,58 @@
# Changelog
## 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)
* Notifications:
* Fix: Incorrect Plex URL parameter value.
* Change: Custom condition logic is now optional. An implicit "and" is applied between all conditions if the logic is blank.
* UI:
* New: Added separate required LAN/WAN bandwidth in the activity header.
* API:
* Fix: Notify API command not sending notifications.
## v2.0.11-beta (2018-01-05)
* Notifications:
* Fix: Some notification parameters showing up blank.
* UI:
* Fix: Stream data showing up as "None" for pre-v2 history.
* Other:
* Fix: Ability to login using the hashed password.
## v2.0.10-beta (2018-01-04)
* Monitoring:
* Fix: HW transcoding indicator on activity cards incorrect after refreshing.
* Notifications:
* Remove: Notification toggles from library and user settings. Use custom conditions to filter out notifications instead.
* UI:
* Fix: Incorrect examples for some date format options. Also added a few missing date format options. (Thanks @Tommatheussen)
## v2.0.9-beta (2018-01-03)
* Notifications:
* Fix: Notifications failing due to incorrect season/episode number types.
## v2.0.8-beta (2018-01-03)
* Monitoring:
* Fix: Fix HW transcoding indicator on activity cards.
* Fix: Fix long product/player names hidden behind platform icon on activity cards.
* Fix: Incorrect HW transcoding indicator on activity cards.
* Fix: Long product/player names hidden behind platform icon on activity cards.
* Notifications:
* Fix: Notifications failing due to some missing notification parameters.

View File

@@ -138,7 +138,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 role="separator" class="divider"></li>
% 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>
% endif
</ul>
@@ -161,7 +161,7 @@ ${next.modalIncludes()}
<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-content">
<form action="${http_root}auth/login" method="post">
<form id="login-form">
<div class="modal-header">
<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>
@@ -190,7 +190,8 @@ ${next.modalIncludes()}
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button>
<span id="incorrect-login" style="padding-right: 25px; display: none;">Incorrect username or password.</span>
<button id="sign-in" type="submit" class="btn btn-bright login-button"><i class="fa fa-sign-in"></i>&nbsp; Sign In</button>
</div>
<input type="hidden" id="admin_login" name="admin_login" value="1" />
</form>
@@ -386,6 +387,29 @@ ${next.modalIncludes()}
$('#admin-login-modal').on('shown.bs.modal', function () {
$('#admin-login-modal #username').focus()
})
$('#login-form').submit(function(event) {
event.preventDefault();
$('#sign-in').prop('disabled', true).html('<i class="fa fa-refresh fa-spin"></i>&nbsp; Sign In');
$.ajax({
url: '${http_root}auth/signin',
type: 'POST',
data: $(this).serialize(),
dataType: 'json',
statusCode: {
200: function() {
window.location = "${http_root}";
},
401: function() {
$('#incorrect-login').show();
$('#username').focus();
}
},
complete: function() {
$('#sign-in').prop('disabled', false).html('<i class="fa fa-sign-in"></i>&nbsp; Sign In');
}
});
});
% endif
</script>
${next.javascriptIncludes()}

View File

@@ -71,11 +71,20 @@ select.form-control {
border-radius: 3px;
transition: background-color .3s;
}
.react-selectize.root-node .react-selectize-control {
.react-selectize.root-node .react-selectize-control,
.selectize-control.form-control .selectize-input {
color: #fff !important;
border: 0px solid #444 !important;
background: #555 !important;
padding: 1px 2px;
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 {
color: #fff !important;
@@ -83,20 +92,67 @@ select.form-control {
.react-selectize.root-node .react-selectize-control .react-selectize-toggle-button path {
fill: #fff !important;
}
.react-selectize.root-node .simple-value,
.selectize-control.multi .selectize-input > div {
background: #444444 !important;
color: #ffffff !important;
padding-bottom: 2px !important;
transition: background-color .3s;
}
.react-selectize.root-node .simple-value span {
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-bottom: 3px !important;
}
select.form-control:focus {
select.form-control:focus,
.react-selectize.root-node.open .react-selectize-control,
.selectize-control.form-control .selectize-input.focus {
outline: 0;
outline: thin dotted \9;
color: #555;
background-color: #fff;
color: #555 !important;
background-color: #fff !important;
transition: background-color .3s;
}
.react-selectize.root-node.open .simple-value,
.selectize-control.multi .selectize-input.focus > div,
.selectize-control.multi .selectize-input > div.active {
background: #efefef !important;
color: #333333 !important;
transition: background-color .3s;
}
.react-selectize.root-node.open .react-selectize-control .react-selectize-toggle-button path {
fill: #999 !important;
}
.selectize-control .selectize-input > div .email {
opacity: 0.8;
font-size: 12px;
}
.selectize-control .selectize-input > div .user + .email {
margin-left: 5px;
}
.selectize-control .selectize-input > div .email:before {
content: '<';
opacity: 0.8;
font-size: 12px;
}
.selectize-control .selectize-input > div .email:after {
content: '>';
opacity: 0.8;
font-size: 12px;
}
.selectize-control .selectize-dropdown .caption {
font-size: 12px;
display: block;
color: #a0a0a0;
}
select.form-control option {
color: #555;
background-color: #fff;
@@ -1273,7 +1329,7 @@ a .dashboard-activity-metadata-user-thumb:hover {
.dashboard-stats-info {
width: 100%;
font-size: 12px;
padding: 3px 0 5px 15px;
padding: 3px 0 0 15px;
position: relative;
}
.dashboard-stats-info-list {
@@ -3722,7 +3778,11 @@ a:hover .overlay-refresh-image:hover {
.no-image {
background-image: none !important;
}
#info-modal .stream-info-current {
color: #aaa;
text-align: center;
padding-bottom: 10px;
}
#info-modal .stream-info-item {
display: flex;
flex-direction: row;

View File

@@ -47,24 +47,12 @@ DOCUMENTATION :: END
</div>
<p class="help-block">Change the library's picture in Tautulli. To reset to default, leave this field empty and save.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="do_notify" name="do_notify" value="1" ${helpers.checked(data['do_notify'])}> Enable notifications
</label>
<p class="help-block">Uncheck this if you do not want to receive notifications for this library's activity.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="keep_history" name="keep_history" value="1" ${helpers.checked(data['keep_history'])}> Keep history
</label>
<p class="help-block">Uncheck this if you do not want to keep any history on this library's activity.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="do_notify_created" name="do_notify_created" value="1" ${helpers.checked(data['do_notify_created'])}> Enable recently added notifications
</label>
<p class="help-block">Uncheck this if you do not want to receive recently added notifications for this library.</p>
</div>
% if data['section_id']:
<div class="form-group">
<button class="btn btn-danger" id="delete-all-history">Purge</button>
@@ -85,15 +73,7 @@ DOCUMENTATION :: END
// Save library options
$("#save_library").on('click', function () {
var custom_thumb = $("#custom_thumb_url").val();
var do_notify = 0;
var do_notify_created = 0;
var keep_history = 0;
if ($("#do_notify").is(":checked")) {
do_notify = 1;
}
if ($("#do_notify_created").is(":checked")) {
do_notify_created = 1;
}
if ($("#keep_history").is(":checked")) {
keep_history = 1;
}
@@ -103,8 +83,6 @@ DOCUMENTATION :: END
data: {
section_id: '${data["section_id"]}',
custom_thumb: custom_thumb,
do_notify: do_notify,
do_notify_created: do_notify_created,
keep_history: keep_history
},
cache: false,

View File

@@ -56,12 +56,6 @@ DOCUMENTATION :: END
</div>
<p class="help-block">Change the users profile picture in Tautulli. To reset to default, leave this field empty and save.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="do_notify" name="do_notify" value="1" ${helpers.checked(data['do_notify'])}> Enable notifications
</label>
<p class="help-block">Uncheck this if you do not want to receive notifications for this user's activity.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="keep_history" name="keep_history" value="1" ${helpers.checked(data['keep_history'])}> Keep history
@@ -95,12 +89,8 @@ DOCUMENTATION :: END
$("#save_user").on('click', function () {
var friendly_name = $("input#friendly_name").val();
var custom_thumb = $("#custom_avatar_url").val();
var do_notify = 0;
var keep_history = 0;
var allow_guest = 0;
if ($("#do_notify").is(":checked")) {
do_notify = 1;
}
if ($("#keep_history").is(":checked")) {
keep_history = 1;
}
@@ -114,7 +104,6 @@ DOCUMENTATION :: END
user_id: '${data["user_id"]}',
friendly_name: friendly_name,
custom_thumb: custom_thumb,
do_notify: do_notify,
keep_history: keep_history,
allow_guest: allow_guest
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -292,7 +292,9 @@
var sc_dp = current_activity.stream_count_direct_play,
sc_ds = current_activity.stream_count_direct_stream,
sc_tc = current_activity.stream_count_transcode,
total_bw = current_activity.total_bandwidth;
total_bw = current_activity.total_bandwidth,
lan_bw = current_activity.lan_bandwidth,
wan_bw = current_activity.wan_bandwidth;
var streams_header = stream_count + ' stream' + (stream_count > 1 ? 's' : '') + ' (';
if (sc_dp) {
streams_header += sc_dp + ' direct play' + (sc_dp > 1 ? 's' : '') + ', ';
@@ -306,7 +308,14 @@
streams_header = streams_header.replace(/, $/, '') + ')';
$('#currentActivityHeader-streams').text(streams_header);
var bandwidth_header = (total_bw > 1000) ? ((total_bw / 1000).toFixed(1) + ' Mbps') : (total_bw + ' kbps');
var bandwidth_header = ((total_bw > 1000) ? ((total_bw / 1000).toFixed(1) + ' Mbps') : (total_bw + ' kbps')) + ' (';
if (lan_bw) {
bandwidth_header += 'LAN: ' + ((lan_bw > 1000) ? ((lan_bw / 1000).toFixed(1) + ' Mbps') : (lan_bw + ' kbps')) + ', ';
}
if (wan_bw) {
bandwidth_header += 'WAN: ' + ((wan_bw > 1000) ? ((wan_bw / 1000).toFixed(1) + ' Mbps') : (wan_bw + ' kbps')) + ', ';
}
bandwidth_header = bandwidth_header.replace(/, $/, '') + ')';
$('#currentActivityHeader-bandwidth').text(bandwidth_header);
$('#currentActivityHeader').show();
@@ -325,25 +334,26 @@
}
// Update play state icon
var state_icon = '';
switch (s.state) {
case 'playing':
var state_icon = '<i class="fa fa-fw fa-play"></i>&nbsp;';
state_icon = '<i class="fa fa-fw fa-play"></i>&nbsp;';
break;
case 'paused':
var state_icon = '<i class="fa fa-fw fa-pause"></i>&nbsp;';
state_icon = '<i class="fa fa-fw fa-pause"></i>&nbsp;';
break;
case 'buffering':
var state_icon = '<i class="fa fa-fw fa-spinner"></i>&nbsp;';
state_icon = '<i class="fa fa-fw fa-spinner"></i>&nbsp;';
break;
default:
var state_icon = '<i class="fa fa-fw fa-question-circle"></i>&nbsp;';
state_icon = '<i class="fa fa-fw fa-question-circle"></i>&nbsp;';
}
$('#play-state-' + key).html(state_icon).attr('title', capitalizeFirstLetter(s.state));
// Switching tracks can be under the same session key, so need to update the info.
if (s.media_type === 'track') {
// Update if artist changed
if (s.grandparent_rating_key != instance.data('grandparent_rating_key')) {
if (s.grandparent_rating_key !== instance.data('grandparent_rating_key')) {
$('#background-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.art + '&width=500&height=280&fallback=art&refresh=true)');
$('#metadata-grandparent_title-' + key)
.attr('href', 'info?rating_key=' + s.grandparent_rating_key)
@@ -351,7 +361,7 @@
.text(s.grandparent_title);
}
// Update cover if album changed
if (s.parent_rating_key != instance.data('parent_rating_key')) {
if (s.parent_rating_key !== instance.data('parent_rating_key')) {
$('#poster-' + key).css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&fallback=poster&refresh=true)');
$('#poster-' + key + '-bg').css('background-image', 'url(pms_image_proxy?img=' + s.parent_thumb + '&width=300&height=300&fallback=poster&refresh=true)');
$('#poster-url-' + key)
@@ -363,7 +373,7 @@
.text(s.parent_title);
}
// Update cover if track changed
if (s.parent_rating_key != instance.data('parent_rating_key')) {
if (s.parent_rating_key !== instance.data('parent_rating_key')) {
$('#metadata-title-' + key)
.attr('href', 'info?rating_key=' + s.rating_key)
.attr('title', s.title)
@@ -374,7 +384,7 @@
// Update the transcode state
var transcode_decision = '';
if (s.transcode_decision === 'transcode') {
var throttled = (s.transcode_throttled == 1) ? ' (Throttled)' : ' (Speed: ' + s.transcode_speed + ')';
var throttled = (s.transcode_throttled === 1) ? ' (Throttled)' : ' (Speed: ' + s.transcode_speed + ')';
transcode_decision = 'Transcode' + throttled;
} else if (s.transcode_decision === 'copy') {
transcode_decision = 'Direct Stream';
@@ -392,36 +402,32 @@
$('#transcode_container-' + key).html(transcode_container);
var video_decision = '';
if (['movie', 'episode', 'clip'].indexOf(s.media_type) > -1 && s.video_decision != '') {
if (['movie', 'episode', 'clip'].indexOf(s.media_type) > -1 && s.video_decision !== '') {
var v_res= '';
switch (s.video_resolution.toLowerCase()) {
case 'sd':
var v_res = 'SD';
v_res = 'SD';
break;
case '4k':
var v_res = '4k';
v_res = '4k';
break;
default:
var v_res = s.video_resolution + 'p'
v_res = s.video_resolution + 'p'
}
var sv_res = '';
switch (s.stream_video_resolution.toLowerCase()) {
case 'sd':
var sv_res = 'SD';
sv_res = 'SD';
break;
case '4k':
var sv_res = '4k';
sv_res = '4k';
break;
default:
var sv_res = s.stream_video_resolution + 'p'
sv_res = s.stream_video_resolution + 'p'
}
if (s.stream_video_decision === 'transcode') {
var hw_d = '';
var hw_e = '';
if (s.transcode_hw_requested === 1 && s.transcode_hw_full_pipeline === 0) {
hw_d = ' (HW)';
} else if (s.transcode_hw_requested === 1 && s.transcode_hw_full_pipeline === 1) {
hw_d = ' (HW)';
hw_e = ' (HW)';
}
var hw_d = (s.transcode_hw_decoding === 1) ? ' (HW)' : '';
var hw_e = (s.transcode_hw_encoding === 1) ? ' (HW)' : '';
video_decision = 'Transcode (' + s.video_codec.toUpperCase() + hw_d + ' ' + v_res + ' &rarr; ' + s.stream_video_codec.toUpperCase() + hw_e + ' ' + sv_res + ')';
} else if (s.stream_video_decision === 'copy') {
video_decision = 'Direct Stream (' + s.stream_video_codec.toUpperCase() + ' ' + sv_res + ')';
@@ -434,7 +440,7 @@
$('#video_decision-' + key).html(video_decision);
var audio_decision = '';
if (['movie', 'episode', 'clip', 'track'].indexOf(s.media_type) > -1 && s.audio_codec) {
if (['movie', 'episode', 'clip', 'track'].indexOf(s.media_type) > -1 && s.audio_decision) {
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();
if (s.stream_audio_decision === 'transcode') {
@@ -456,13 +462,13 @@
} else if (s.stream_subtitle_decision === 'burn') {
subtitle_decision = 'Burn (' + s.subtitle_codec.toUpperCase() + ')';
} else {
subtitle_decision = 'Direct Play (' + ((s.synced_version == '1') ? s.stream_subtitle_codec.toUpperCase() : s.subtitle_codec.toUpperCase()) + ')';
subtitle_decision = 'Direct Play (' + ((s.synced_version === '1') ? s.stream_subtitle_codec.toUpperCase() : s.subtitle_codec.toUpperCase()) + ')';
}
}
$('#subtitle_decision-' + key).html(subtitle_decision);
// Update the stream quality profile and bandwidth
if (s.media_type != 'photo' && s.quality_profile != 'Unknown') {
if (s.media_type !== 'photo' && s.quality_profile !== 'Unknown') {
var br = parseInt(s.stream_bitrate) || '';
if (br) {
if (br > 1000) {
@@ -478,9 +484,9 @@
$('#optimized_version-' + key).html(s.optimized_version_profile + ' (' + s.optimized_version_title + ')');
$('#synced_quality_profile-' + key).html(s.synced_quality_profile);
if (s.media_type != 'photo' && parseInt(s.bandwidth)) {
if (s.media_type !== 'photo' && parseInt(s.bandwidth)) {
var bw = parseInt(s.bandwidth);
if (bw != "Unknown") {
if (bw !== "Unknown") {
if (bw > 1000) {
bw = (bw / 1000).toFixed(1) + ' Mbps';
} else {
@@ -492,17 +498,19 @@
// Update the stream progress times
$('#stream-eta-' + key).html(moment().add(parseInt(s.duration) - parseInt(s.view_offset), 'milliseconds').format(time_format));
$('#stream-view-offset-' + key).data('state', s.state);
if ($('#stream-view-offset-' + key).data('last_view_offset') != s.view_offset) {
$('#stream-view-offset-' + key).data('last_view_offset', s.view_offset).data('view_offset', s.view_offset);
var stream_view_offset = $('#stream-view-offset-' + key);
stream_view_offset.data('state', s.state);
if (stream_view_offset.data('last_view_offset') !== s.view_offset) {
stream_view_offset.data('last_view_offset', s.view_offset).data('view_offset', s.view_offset);
}
// Update the progress bars, percent - 3 because of 3px padding-right
$('#buffer-bar-' + key).width(parseInt(s.transcode_progress) - 3 + '%').html(s.transcode_progress + '%')
.attr('data-original-title', 'Transcoder Progress ' + s.transcode_progress + '%');
$('#progress-bar-' + key).data('state', s.state);
if ($('#progress-bar-' + key).data('last_view_offset') != s.view_offset) {
$('#progress-bar-' + key).data('last_view_offset', s.view_offset).data('view_offset', s.view_offset);
var progress_bar = $('#progress-bar-' + key);
progress_bar.data('state', s.state);
if (progress_bar.data('last_view_offset') !== s.view_offset) {
progress_bar.data('last_view_offset', s.view_offset).data('view_offset', s.view_offset);
}
// Add temporary class so we know which instances are still active
@@ -771,13 +779,13 @@
leftTotal = Math.max(Math.min(leftTotal + scrollAmount, 0), leftMax);
scroller.animate({ left: leftTotal }, 250);
if (leftTotal == 0) {
if (leftTotal === 0) {
$("#recently-added-page-left").addClass("disabled").blur();
} else {
$("#recently-added-page-left").removeClass("disabled");
}
if (leftTotal == leftMax) {
if (leftTotal === leftMax) {
$("#recently-added-page-right").addClass("disabled").blur();
} else {
$("#recently-added-page-right").removeClass("disabled");

View File

@@ -38,20 +38,21 @@ DOCUMENTATION :: END
<%!
import re
from plexpy import common, notifiers
from plexpy import notifiers
from plexpy.common import MEDIA_TYPE_HEADERS, MEDIA_FLAGS_AUDIO, MEDIA_FLAGS_VIDEO
# Get audio codec file
def af(codec):
for pattern, file in common.MEDIA_FLAGS_AUDIO.iteritems():
for pattern, file_type in MEDIA_FLAGS_AUDIO.iteritems():
if re.match(pattern, codec):
return file
return file_type
return codec
# Get audio codec file
def vf(codec):
for pattern, file in common.MEDIA_FLAGS_VIDEO.iteritems():
for pattern, file_type in MEDIA_FLAGS_VIDEO.iteritems():
if re.match(pattern, codec):
return file
return file_type
return codec
def br(text):
@@ -356,7 +357,7 @@ DOCUMENTATION :: END
<div class="col-md-12">
<div class="table-card-header">
<div class="header-bar">
<span>Movies in <strong>${data['title']}</strong> collection</span>
<span>${MEDIA_TYPE_HEADERS[data['sub_media_type']]} in <strong>${data['title']}</strong> collection</span>
</div>
</div>
<div class="table-card-back">

View File

@@ -28,22 +28,15 @@ DOCUMENTATION :: END
% if data != None:
<%
from plexpy.common import MEDIA_TYPE_HEADERS
types = ('movie', 'show', 'artist', 'album')
headers = {'movie': 'Movies',
'show': 'TV Shows',
'season': 'Seasons',
'episode': 'Episodes',
'artist': 'Artists',
'album': 'Albums',
'track': 'Tracks',
}
%>
% for media_type in types:
% if data['results_list'][media_type]:
<div class="col-md-12">
<div class="table-card-header">
<div class="header-bar">
<span>${headers[media_type]} in <strong>${title}</strong> collection</span>
<span>${MEDIA_TYPE_HEADERS[media_type]} in <strong>${title}</strong> collection</span>
</div>
</div>
<div class="table-card-back">

View File

@@ -28,9 +28,7 @@ libraries_list_table_options = {
$(td).html('<div class="edit-library-toggles">' +
'<button class="btn btn-xs btn-warning delete-library" data-id="' + rowData['section_id'] + '" data-toggle="button"><i class="fa fa-trash-o fa-fw"></i> Delete</button>&nbsp' +
'<button class="btn btn-xs btn-warning purge-library" data-id="' + rowData['section_id'] + '" data-toggle="button"><i class="fa fa-eraser fa-fw"></i> Purge</button>&nbsp&nbsp&nbsp' +
'<input type="checkbox" id="do_notify-' + rowData['section_id'] + '" name="do_notify" value="1" ' + rowData['do_notify'] + '><label class="edit-tooltip" for="do_notify-' + rowData['section_id'] + '" data-toggle="tooltip" title="Toggle Notifications"><i class="fa fa-bell fa-lg fa-fw"></i></label>&nbsp' +
'<input type="checkbox" id="keep_history-' + rowData['section_id'] + '" name="keep_history" value="1" ' + rowData['keep_history'] + '><label class="edit-tooltip" for="keep_history-' + rowData['section_id'] + '" data-toggle="tooltip" title="Toggle History"><i class="fa fa-history fa-lg fa-fw"></i></label>&nbsp' +
'<input type="checkbox" id="do_notify_created-' + rowData['section_id'] + '" name="do_notify_created" value="1" ' + rowData['do_notify_created'] + '><label class="edit-tooltip" for="do_notify_created-' + rowData['section_id'] + '" data-toggle="tooltip" title="Toggle Recently Added"><i class="fa fa-download fa-lg fa-fw"></i></label>&nbsp' +
'</div>');
},
"width": "7%",
@@ -258,15 +256,7 @@ $('#libraries_list_table').on('change', 'td.edit-control > .edit-library-toggles
var row = libraries_list_table.row(tr);
var rowData = row.data();
var do_notify = 0;
var do_notify_created = 0;
var keep_history = 0;
if ($('#do_notify-' + rowData['section_id']).is(':checked')) {
do_notify = 1;
}
if ($('#do_notify_created-' + rowData['section_id']).is(':checked')) {
do_notify_created = 1;
}
if ($('#keep_history-' + rowData['section_id']).is(':checked')) {
keep_history = 1;
}
@@ -280,8 +270,6 @@ $('#libraries_list_table').on('change', 'td.edit-control > .edit-library-toggles
url: 'edit_library',
data: {
section_id: rowData['section_id'],
do_notify: do_notify,
do_notify_created: do_notify_created,
keep_history: keep_history,
custom_thumb: custom_thumb
},

View File

@@ -45,7 +45,6 @@ users_list_table_options = {
$(td).html('<div class="edit-user-toggles">' +
'<button class="btn btn-xs btn-warning delete-user" data-id="' + rowData['user_id'] + '" data-toggle="button"><i class="fa fa-trash-o fa-fw"></i> Delete</button>&nbsp' +
'<button class="btn btn-xs btn-warning purge-user" data-id="' + rowData['user_id'] + '" data-toggle="button"><i class="fa fa-eraser fa-fw"></i> Purge</button>&nbsp&nbsp&nbsp' +
'<input type="checkbox" id="do_notify-' + rowData['user_id'] + '" name="do_notify" value="1" ' + rowData['do_notify'] + '><label class="edit-tooltip" for="do_notify-' + rowData['user_id'] + '" data-toggle="tooltip" title="Toggle Notifications"><i class="fa fa-bell fa-lg fa-fw"></i></label>&nbsp' +
'<input type="checkbox" id="keep_history-' + rowData['user_id'] + '" name="keep_history" value="1" ' + rowData['keep_history'] + '><label class="edit-tooltip" for="keep_history-' + rowData['user_id'] + '" data-toggle="tooltip" title="Toggle History"><i class="fa fa-history fa-lg fa-fw"></i></label>&nbsp' +
'<input type="checkbox" id="allow_guest-' + rowData['user_id'] + '" name="allow_guest" value="1" ' + rowData['allow_guest'] + '><label class="edit-tooltip" for="allow_guest-' + rowData['user_id'] + '" data-toggle="tooltip" title="Toggle Guest Access"><i class="fa fa-unlock-alt fa-lg fa-fw"></i></label>&nbsp' +
'</div>');
@@ -284,12 +283,8 @@ $('#users_list_table').on('change', 'td.edit-control > .edit-user-toggles > inpu
var row = users_list_table.row(tr);
var rowData = row.data();
var do_notify = 0;
var keep_history = 0;
var allow_guest = 0;
if ($('#do_notify-' + rowData['user_id']).is(':checked')) {
do_notify = 1;
}
if ($('#keep_history-' + rowData['user_id']).is(':checked')) {
keep_history = 1;
}
@@ -304,7 +299,6 @@ $('#users_list_table').on('change', 'td.edit-control > .edit-user-toggles > inpu
data: {
user_id: rowData['user_id'],
friendly_name: friendly_name,
do_notify: do_notify,
keep_history: keep_history,
allow_guest: allow_guest,
thumb: rowData['user_thumb']

View File

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

View File

@@ -1,6 +1,10 @@
<%!
from plexpy import helpers, notifiers
import json
from plexpy import helpers, notifiers, users
available_notification_actions = notifiers.available_notification_actions()
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:
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
@@ -39,7 +43,7 @@
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<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 ''}>
% if item['name'] == 'osx_notify_app':
<a href="javascript:void(0)" id="osxnotifyregister">Register</a>
@@ -62,7 +66,7 @@
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<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']}">
</div>
</div>
@@ -80,7 +84,7 @@
<div class="form-group">
<label for="${item['name']}">${item['label']}</label>
<div class="row">
<div class="col-md-8">
<div class="col-md-12">
<select class="form-control" id="${item['name']}" name="${item['name']}">
% for key, value in sorted(item['select_options'].iteritems()):
% if key == item['value']:
@@ -101,7 +105,7 @@
<div class="form-group">
<label for="friendly_name">Description</label>
<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">
</div>
</div>
@@ -132,12 +136,9 @@
<div role="tabpanel" class="tab-pane" id="tabs-notify_conditions">
<label>Notification Conditions</label>
<p class="help-block">
Add custom notification conditions.
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.
</p>
<p class="help-block">
Note: Conditions are checked after the notification trigger and the notification will only be sent if the condition logic is satisfied.
</p>
<div id="condition-widget"></div>
<input type="hidden" name="custom_conditions" id="custom_conditions" />
@@ -146,7 +147,8 @@
<input type="text" class="form-control" name="custom_conditions_logic" id="custom_conditions_logic" value="${notifier['custom_conditions_logic']}" required />
<div id="custom_conditions_logic_error" class="alert alert-danger" role="alert" style="padding-top: 5px; padding-bottom: 5px; margin: 0; display: none;"><i class="fa fa-exclamation-triangle" style="color: #a94442;"></i> <span></span></div>
<p class="help-block">
Enter the logic to use when evaluating the conditions (e.g. <span class="inline-pre">{1} and ({2} or {3})</span>).
Optional: Enter custom logic to use when evaluating the conditions (e.g. <span class="inline-pre">{1} and ({2} or {3})</span>).
Leave blank for implicit <span class="inline-pre">and</span> between all conditions.
</p>
<p class="help-block">
Note: Only the keywords <span class="inline-pre">and</span>/<span class="inline-pre">or</span> and brackets <span class="inline-pre">()</span> are supported.
@@ -187,7 +189,7 @@
</div>
<div class="form-group">
<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">
</div>
</div>
@@ -214,7 +216,7 @@
</div>
<div class="form-group">
<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">
</div>
</div>
@@ -280,7 +282,7 @@
% endif
<div class="form-group">
<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']}">
</div>
</div>
@@ -467,6 +469,80 @@
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); });
})
% 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,
valueField: 'email',
labelField: 'user',
searchField: ['user', 'email'],
options: ${json.dumps(user_emails) | n},
render: {
item: function(item, escape) {
return '<div>' +
(item.user ? '<span class="user">' + escape(item.user) + '</span>' : '') +
(item.email ? '<span class="email">' + escape(item.email) + '</span>' : '') +
'</div>';
},
option: function(item, escape) {
var label = item.user || item.email;
var caption = item.user ? item.email : null;
return '<div>' +
escape(label) +
(caption ? '<span class="caption">' + escape(caption) + '</span>' : '') +
'</div>';
}
},
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 {email: input};
}
var match = input.match(new RegExp('^([^<]*)\<' + REGEX_EMAIL + '\>$', 'i'));
if (match) {
return {
email : match[2],
user : $.trim(match[1])
};
}
alert('Invalid email address.');
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;
console.log(${json.dumps(next((c['value'] for c in notifier['config_options'] if c['name'] == 'join_device_names'), [])) | n});
join_device_names.setValue(${json.dumps(next((c['value'] for c in notifier['config_options'] if c['name'] == 'join_device_names'), [])) | n});
% endif
function validateLogic() {

View File

@@ -63,7 +63,7 @@ DOCUMENTATION :: END
<h3 class="text-muted">&nbsp;</h3>
</div>
% elif item['media_type'] == 'show':
<a href="info?rating_key=${item['rating_key']}" title="${item['parent_title']}">
<a href="info?rating_key=${item['rating_key']}" title="${item['title']}">
<div class="dashboard-recent-media-poster">
<div class="dashboard-recent-media-poster-face" style="background-image: url(pms_image_proxy?img=${item['thumb']}&width=300&height=450&fallback=poster);">
<div class="dashboard-recent-media-overlay">

View File

@@ -13,6 +13,8 @@
</%def>
<%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 name="body()">
@@ -470,6 +472,13 @@
</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]"
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">
<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
@@ -477,6 +486,7 @@
<p class="help-block">Use basic HTTP authentication instead of the HTML login form.</p>
</div>
<input type="checkbox" name="auth_changed" id="auth_changed" value="1" style="display: none;">
<div class="padded-header">
<h3>Guest Access</h3>
@@ -918,7 +928,7 @@
<div class="checkbox">
<label>
<input type="checkbox" id="get_file_sizes" name="get_file_sizes" value="1" ${config['get_file_sizes']}> Calculate Total File Sizes <span style="color: #eb8600; padding-left: 10px;">[experimental]</span>
<input type="checkbox" id="get_file_sizes" name="get_file_sizes" value="1" ${config['get_file_sizes']}> Calculate Total File Sizes
</label>
<p class="help-block">Enable if you want Tautulli to calculate the total file size for TV Shows/Seasons and Artists/Albums on the media info tables.</p>
</div>
@@ -1500,6 +1510,7 @@
<%def name="javascriptIncludes()">
<script src="${http_root}js/parsley.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/jquery.qrcode.min.js"></script>
<script>
@@ -1774,6 +1785,7 @@ $(document).ready(function() {
$( ".auth-settings" ).change(function() {
authChanged = true;
$("#auth_changed").prop('checked', true);
});
$( ".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 () {
if ($("#http_basic_auth").is(":checked")) {
$("#allow_guest_access").attr("disabled", true);
@@ -2021,7 +2053,7 @@ $(document).ready(function() {
} else if ($('#http_username').val() == '' || $('#http_password').val() == '') {
$("#allow_guest_access").attr("disabled", true);
$("#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 {
$("#allow_guest_access").attr("disabled", false);
$("#allowGuestCheck").html("");

View File

@@ -54,6 +54,11 @@ DOCUMENTATION :: END
</h4>
</div>
<div class="modal-body">
% if data['current_session']:
<div class="col-sm-12 text-muted stream-info-current">
<i class="fa fa-exclamation-circle"></i> Current session. Updated stream details below may be delayed.
</div>
% endif
<table class="stream-info" style="margin-top: 0;">
<thead>
<tr>

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

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

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

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

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

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

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

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

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

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

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

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

View File

View File

View File

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

View File

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

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

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

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

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

View File

@@ -175,17 +175,21 @@ def initialize(config_file):
# Check if Tautulli has a uuid
if CONFIG.PMS_UUID == '' or not CONFIG.PMS_UUID:
logger.debug(u"Generating UUID...")
my_uuid = generate_uuid()
CONFIG.__setattr__('PMS_UUID', my_uuid)
CONFIG.PMS_UUID = generate_uuid()
CONFIG.write()
# Check if Tautulli has an API key
if CONFIG.API_KEY == '':
logger.debug(u"Generating API key...")
api_key = generate_uuid()
CONFIG.__setattr__('API_KEY', api_key)
CONFIG.API_KEY = generate_uuid()
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 currently installed version. Returns None, 'win32' or the git
# hash.
CURRENT_VERSION, CONFIG.GIT_REMOTE, CONFIG.GIT_BRANCH = versioncheck.getVersion()
@@ -498,7 +502,7 @@ def dbcheck():
c_db.execute(
'CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'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, '
'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, '
@@ -1189,6 +1193,27 @@ def dbcheck():
)
# Upgrade session_history_media_info table from earlier versions
try:
result = c_db.execute('SELECT stream_container FROM session_history_media_info '
'WHERE stream_container IS NULL').fetchall()
if len(result) > 0:
logger.debug(u"Altering database. Removing NULL values from session_history_media_info table.")
c_db.execute(
'UPDATE session_history_media_info SET stream_container = "" WHERE stream_container IS NULL '
)
c_db.execute(
'UPDATE session_history_media_info SET stream_video_codec = "" WHERE stream_video_codec IS NULL '
)
c_db.execute(
'UPDATE session_history_media_info SET stream_audio_codec = "" WHERE stream_audio_codec IS NULL '
)
c_db.execute(
'UPDATE session_history_media_info SET stream_subtitle_codec = "" WHERE stream_subtitle_codec IS NULL '
)
except sqlite3.OperationalError:
logger.warn(u"Unable to remove NULL values from session_history_media_info table.")
# Upgrade users table from earlier versions
try:
c_db.execute('SELECT do_notify FROM users')
@@ -1264,6 +1289,15 @@ def dbcheck():
'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
try:
c_db.execute('SELECT poster_url FROM notify_log')
@@ -1370,8 +1404,8 @@ def dbcheck():
# Upgrade library_sections table from earlier versions (remove duplicated libraries)
try:
result = c_db.execute('SELECT * FROM library_sections WHERE server_id = ""')
if result.rowcount > 0:
result = c_db.execute('SELECT * FROM library_sections WHERE server_id = ""').fetchall()
if len(result) > 0:
logger.debug(u"Altering database. Removing duplicate libraries from library_sections table.")
c_db.execute(
'DELETE FROM library_sections WHERE server_id = ""'

View File

@@ -33,6 +33,7 @@ ACTIVITY_SCHED = BackgroundScheduler()
RECENTLY_ADDED_QUEUE = {}
class ActivityHandler(object):
def __init__(self, timeline):
@@ -229,9 +230,11 @@ class ActivityHandler(object):
# Update the session state and viewOffset
if this_state == 'playing':
# Update the session in our temp session table
session = self.get_live_session()
if session:
self.update_db_session(session=session)
# if the last set temporary stopped time exceeds 15 seconds
if int(time.time()) - db_session['stopped'] > 60:
session = self.get_live_session()
if session:
self.update_db_session(session=session)
# Start our state checks
if this_state != last_state:
@@ -497,14 +500,16 @@ def on_created(rating_key, **kwargs):
notify = True
now = int(time.time())
if helpers.cast_to_int(metadata['updated_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))
if helpers.cast_to_int(metadata['added_at']) < now - 86400: # Updated more than 24 hours ago
logger.debug(u"Tautulli TimelineHandler :: Library item %s added more than 24 hours ago. Not notifying."
% str(rating_key))
notify = False
data_factory = datafactory.DataFactory()
if 'child_keys' not in kwargs:
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
if notify:

View File

@@ -35,6 +35,8 @@ import database
import libraries
import logger
import mobile_app
import notification_handler
import notifiers
import users
@@ -397,6 +399,50 @@ class API2:
return
def notify(self, notifier_id='', subject='Tautulli', body='Test notification', **kwargs):
""" Send a notification using Tautulli.
```
Required parameters:
notifier_id (int): The ID number of the notification agent
subject (str): The subject of the message
body (str): The body of the message
Optional parameters:
None
Returns:
None
```
"""
if not notifier_id:
self._api_msg = 'Notification failed: no notifier id provided.'
self._api_result_type = 'error'
return
notifier = notifiers.get_notifier_config(notifier_id=notifier_id)
if not notifier:
self._api_msg = 'Notification failed: invalid notifier_id provided %s.' % notifier_id
self._api_result_type = 'error'
return
logger.api_debug(u'Tautulli APIv2 :: Sending notification.')
success = notification_handler.notify(notifier_id=notifier_id,
notify_action='api',
subject=subject,
body=body,
**kwargs)
if success:
self._api_msg = 'Notification sent.'
self._api_result_type = 'success'
else:
self._api_msg = 'Notification failed.'
self._api_result_type = 'error'
return
def _api_make_md(self):
""" Tries to make a API.md to simplify the api docs. """
@@ -581,8 +627,8 @@ General optional parameters:
if isinstance(result, (dict, list)):
ret = result
else:
raise
except:
raise Exception
except Exception:
try:
ret = json.loads(result)
except (ValueError, TypeError):

View File

@@ -32,133 +32,176 @@ DEFAULT_POSTER_THUMB = "interfaces/default/images/poster.png"
DEFAULT_COVER_THUMB = "interfaces/default/images/cover.png"
DEFAULT_ART = "interfaces/default/images/art.png"
PLATFORM_NAME_OVERRIDES = {'Konvergo': 'Plex Media Player',
'Mystery 3': 'Playstation 3',
'Mystery 4': 'Playstation 4',
'Mystery 5': 'Xbox 360',
'WebMAF': 'Playstation 4'
}
MEDIA_TYPE_HEADERS = {
'movie': 'Movies',
'show': 'TV Shows',
'season': 'Seasons',
'episode': 'Episodes',
'artist': 'Artists',
'album': 'Albums',
'track': 'Tracks',
}
PMS_PLATFORM_NAME_OVERRIDES = {'MacOSX': 'Mac'
}
PLATFORM_NAME_OVERRIDES = {
'Konvergo': 'Plex Media Player',
'Mystery 3': 'Playstation 3',
'Mystery 4': 'Playstation 4',
'Mystery 5': 'Xbox 360',
'WebMAF': 'Playstation 4'
}
PLATFORM_NAMES = {'android': 'android',
'apple tv': 'atv',
'chrome': 'chrome',
'chromecast': 'chromecast',
'dlna': 'dlna',
'firefox': 'firefox',
'internet explorer': 'ie',
'ios': 'ios',
'ipad': 'ios',
'iphone': 'ios',
'kodi': 'kodi',
'linux': 'linux',
'nexus': 'android',
'macos': 'macos',
'microsoft edge': 'msedge',
'opera': 'opera',
'osx': 'macos',
'playstation': 'playstation',
'plex home theater': 'plex',
'plex media player': 'plex',
'plexamp': 'plexamp',
'plextogether': 'synclounge',
'roku': 'roku',
'safari': 'safari',
'samsung': 'samsung',
'synclounge': 'synclounge',
'tivo': 'tivo',
'tvos': 'atv',
'vizio': 'opera',
'wiiu': 'wiiu',
'windows': 'windows',
'windows phone': 'wp',
'xbmc': 'xbmc',
'xbox': 'xbox'
}
PMS_PLATFORM_NAME_OVERRIDES = {
'MacOSX': 'Mac'
}
PLATFORM_NAMES = {
'android': 'android',
'apple tv': 'atv',
'chrome': 'chrome',
'chromecast': 'chromecast',
'dlna': 'dlna',
'firefox': 'firefox',
'internet explorer': 'ie',
'ios': 'ios',
'ipad': 'ios',
'iphone': 'ios',
'kodi': 'kodi',
'linux': 'linux',
'nexus': 'android',
'macos': 'macos',
'microsoft edge': 'msedge',
'opera': 'opera',
'osx': 'macos',
'playstation': 'playstation',
'plex home theater': 'plex',
'plex media player': 'plex',
'plexamp': 'plexamp',
'plextogether': 'synclounge',
'roku': 'roku',
'safari': 'safari',
'samsung': 'samsung',
'synclounge': 'synclounge',
'tivo': 'tivo',
'tvos': 'atv',
'vizio': 'opera',
'wiiu': 'wiiu',
'windows': 'windows',
'windows phone': 'wp',
'xbmc': 'xbmc',
'xbox': 'xbox'
}
PLATFORM_NAMES = OrderedDict(sorted(PLATFORM_NAMES.items(), key=lambda k: k[0], reverse=True))
MEDIA_FLAGS_AUDIO = {'ac.?3': 'dolby_digital',
'truehd': 'dolby_truehd',
'(dca|dta)': 'dts',
'dts(hd_|-hd|-)?ma': 'dca-ma',
'vorbis': 'ogg'
}
MEDIA_FLAGS_VIDEO = {'avc1': 'h264',
'wmv(1|2)': 'wmv',
'wmv3': 'wmvhd'
}
MEDIA_FLAGS_AUDIO = {
'ac.?3': 'dolby_digital',
'truehd': 'dolby_truehd',
'(dca|dta)': 'dts',
'dts(hd_|-hd|-)?ma': 'dca-ma',
'vorbis': 'ogg'
}
MEDIA_FLAGS_VIDEO = {
'avc1': 'h264',
'wmv(1|2)': 'wmv',
'wmv3': 'wmvhd'
}
AUDIO_CODEC_OVERRIDES = {'truehd': 'TrueHD'}
AUDIO_CODEC_OVERRIDES = {
'truehd': 'TrueHD'
}
VIDEO_RESOLUTION_OVERRIDES = {'sd': 'SD',
'480': '480p',
'540': '540p',
'576': '576p',
'720': '720p',
'1080': '1080p',
'4k': '4k'
}
VIDEO_RESOLUTION_OVERRIDES = {
'sd': 'SD',
'480': '480p',
'540': '540p',
'576': '576p',
'720': '720p',
'1080': '1080p',
'4k': '4k'
}
AUDIO_CHANNELS = {'1': 'Mono',
'2': 'Stereo',
'3': '2.1',
'4': '3.1',
'6': '5.1',
'7': '6.1',
'8': '7.1'
}
AUDIO_CHANNELS = {
'1': 'Mono',
'2': 'Stereo',
'3': '2.1',
'4': '3.1',
'6': '5.1',
'7': '6.1',
'8': '7.1'
}
VIDEO_QUALITY_PROFILES = {20000: '20 Mbps 1080p',
12000: '12 Mbps 1080p',
10000: '10 Mbps 1080p',
8000: '8 Mbps 1080p',
4000: '4 Mbps 720p',
3000: '3 Mbps 720p',
2000: '2 Mbps 720p',
1500: '1.5 Mbps 480p',
720: '0.7 Mbps 328p',
320: '0.3 Mbps 240p',
208: '0.2 Mbps 160p',
96: '0.096 Mbps',
64: '0.064 Mbps'
}
VIDEO_QUALITY_PROFILES = {
20000: '20 Mbps 1080p',
12000: '12 Mbps 1080p',
10000: '10 Mbps 1080p',
8000: '8 Mbps 1080p',
4000: '4 Mbps 720p',
3000: '3 Mbps 720p',
2000: '2 Mbps 720p',
1500: '1.5 Mbps 480p',
720: '0.7 Mbps 328p',
320: '0.3 Mbps 240p',
208: '0.2 Mbps 160p',
96: '0.096 Mbps',
64: '0.064 Mbps'
}
VIDEO_QUALITY_PROFILES = OrderedDict(sorted(VIDEO_QUALITY_PROFILES.items(), key=lambda k: k[0], reverse=True))
AUDIO_QUALITY_PROFILES = {512: '512 kbps',
320: '320 kbps',
256: '256 kbps',
192: '192 kbps',
128: '128 kbps',
96: '96 kbps'
}
AUDIO_QUALITY_PROFILES = {
512: '512 kbps',
320: '320 kbps',
256: '256 kbps',
192: '192 kbps',
128: '128 kbps',
96: '96 kbps'
}
AUDIO_QUALITY_PROFILES = OrderedDict(sorted(AUDIO_QUALITY_PROFILES.items(), key=lambda k: k[0], reverse=True))
HW_DECODERS = ['dxva2', 'videotoolbox', 'mediacodecndk', 'vaapi']
HW_ENCODERS = ['qsv', 'nvenc', 'mf', 'videotoolbox', 'mediacodecndk', 'vaapi', 'nvenc']
HW_DECODERS = [
'dxva2',
'videotoolbox',
'mediacodecndk',
'vaapi'
]
HW_ENCODERS = [
'qsv',
'nvenc',
'mf',
'videotoolbox',
'mediacodecndk',
'vaapi',
'nvenc'
]
SCHEDULER_LIST = ['Check GitHub for updates',
'Check for active sessions',
'Check for recently added items',
'Check for Plex updates',
'Check for Plex remote access',
'Check server response',
'Refresh users list',
'Refresh libraries list',
'Refresh Plex server URLs',
'Backup Tautulli database',
'Backup Tautulli config'
]
SCHEDULER_LIST = [
'Check GitHub for updates',
'Check for active sessions',
'Check for recently added items',
'Check for Plex updates',
'Check for Plex remote access',
'Check server response',
'Refresh users list',
'Refresh libraries list',
'Refresh Plex server URLs',
'Backup Tautulli database',
'Backup Tautulli config'
]
DATE_TIME_FORMATS = [
{
'category': 'Year',
'parameters': [
{'value': 'YYYY', 'description': 'Numeric, four digits', 'example': '1999, 2003'},
{'value': 'YY', 'description': 'Numeric, two digits', 'example': '99, 03'}
]
},
{
'category': 'Month',
'parameters': [
{'value': 'MMMM', 'description': 'Textual, full', 'example': 'January-December'},
{'value': 'MMM', 'description': 'Textual, three letters', 'example': 'Jan-Dec'},
{'value': 'MM', 'description': 'Numeric, with leading zeros', 'example': '42747'},
{'value': 'M', 'description': 'Numeric, without leading zeros', 'example': '42747'},
{'value': 'MM', 'description': 'Numeric, with leading zeros', 'example': '01-12'},
{'value': 'M', 'description': 'Numeric, without leading zeros', 'example': '1-12'},
{'value': 'Mo', 'description': 'Numeric, with suffix', 'example': '1st, 2nd ... 12th'},
]
},
{
@@ -166,14 +209,15 @@ DATE_TIME_FORMATS = [
'parameters': [
{'value': 'DDDD', 'description': 'Numeric, with leading zeros', 'example': '001-365'},
{'value': 'DDD', 'description': 'Numeric, without leading zeros', 'example': '1-365'},
{'value': 'DDDo', 'description': 'Numeric, with suffix', 'example': '1st, 2nd, ... 365th'},
]
},
{
'category': 'Day of the Month',
'parameters': [
{'value': 'DD', 'description': 'Numeric, with leading zeros', 'example': '42766'},
{'value': 'D', 'description': 'Numeric, without leading zeros', 'example': '42766'},
{'value': 'Do', 'description': 'Numeric, with suffix', 'example': 'E.g. 1st, 2nd ... 31st.'},
{'value': 'DD', 'description': 'Numeric, with leading zeros', 'example': '01-31'},
{'value': 'D', 'description': 'Numeric, without leading zeros', 'example': '1-31'},
{'value': 'Do', 'description': 'Numeric, with suffix', 'example': '1st, 2nd ... 31st'},
]
},
{
@@ -181,7 +225,9 @@ DATE_TIME_FORMATS = [
'parameters': [
{'value': 'dddd', 'description': 'Textual, full', 'example': 'Sunday-Saturday'},
{'value': 'ddd', 'description': 'Textual, three letters', 'example': 'Sun-Sat'},
{'value': 'dd', 'description': 'Textual, two letters', 'example': 'Su-Sa'},
{'value': 'd', 'description': 'Numeric', 'example': '0-6'},
{'value': 'do', 'description': 'Numeric, with suffix', 'example': '0th, 1st ... 6th'},
]
},
{
@@ -189,8 +235,8 @@ DATE_TIME_FORMATS = [
'parameters': [
{'value': 'HH', 'description': '24-hour, with leading zeros', 'example': '00-23'},
{'value': 'H', 'description': '24-hour, without leading zeros', 'example': '0-23'},
{'value': 'hh', 'description': '12-hour, with leading zeros', 'example': '42747'},
{'value': 'h', 'description': '12-hour, without leading zeros', 'example': '42747'},
{'value': 'hh', 'description': '12-hour, with leading zeros', 'example': '01-12'},
{'value': 'h', 'description': '12-hour, without leading zeros', 'example': '1-12'},
]
},
{
@@ -217,8 +263,8 @@ DATE_TIME_FORMATS = [
{
'category': 'Timezone',
'parameters': [
{'value': 'ZZ', 'description': 'UTC offset', 'example': 'E.g. +0100, -0700'},
{'value': 'Z', 'description': 'UTC offset', 'example': 'E.g. +01:00, -07:00'},
{'value': 'ZZ', 'description': 'UTC offset', 'example': '+0100, -0700'},
{'value': 'Z', 'description': 'UTC offset', 'example': '+01:00, -07:00'},
]
},
{
@@ -227,7 +273,7 @@ DATE_TIME_FORMATS = [
{'value': 'X', 'description': 'Unix timestamp', 'example': 'E.g. 1456887825'},
]
},
]
]
NOTIFICATION_PARAMETERS = [
{
@@ -342,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': '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': 'Air Date', 'type': 'int', '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': 'Updated Date', 'type': 'int', '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': 'Air Date', 'type': 'str', 'value': 'air_date', 'description': 'The air date (in date format) for the item.'},
{'name': 'Added Date', 'type': 'str', 'value': 'added_date', 'description': 'The date (in date format) the item was added to 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': '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': '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.'},
@@ -354,8 +400,8 @@ NOTIFICATION_PARAMETERS = [
{'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': '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': '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': 'Rating', 'type': 'float', 'value': 'rating', 'description': 'The rating (out of 10) for the item.'},
{'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': '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.'},
@@ -439,4 +485,4 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Plexpy Update Changelog', 'type': 'int', 'value': 'plexpy_update_changelog', 'description': 'The changelog for the available update.'},
]
},
]
]

View File

@@ -225,6 +225,7 @@ _CONFIG_DEFINITIONS = {
'HTTP_PROXY': (int, 'General', 0),
'HTTP_ROOT': (str, 'General', ''),
'HTTP_USERNAME': (str, 'General', ''),
'HTTP_PLEX_ADMIN': (int, 'General', 0),
'HIPCHAT_URL': (str, 'Hipchat', ''),
'HIPCHAT_COLOR': (str, 'Hipchat', ''),
'HIPCHAT_INCL_SUBJECT': (int, 'Hipchat', 1),
@@ -289,6 +290,7 @@ _CONFIG_DEFINITIONS = {
'LOG_BLACKLIST': (int, 'General', 1),
'LOG_DIR': (str, 'General', ''),
'LOGGING_IGNORE_INTERVAL': (int, 'Monitoring', 120),
'METADATA_CACHE_SECONDS': (int, 'Advanced', 1800),
'MOVIE_LOGGING_ENABLE': (int, 'Monitoring', 1),
'MOVIE_NOTIFY_ENABLE': (int, 'Monitoring', 0),
'MOVIE_NOTIFY_ON_START': (int, 'Monitoring', 1),
@@ -610,7 +612,8 @@ _CONFIG_DEFINITIONS = {
'XBMC_ON_INTUP': (int, 'XBMC', 0),
'XBMC_ON_PMSUPDATE': (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']

View File

@@ -951,9 +951,11 @@ class DataFactory(object):
'transcode_hw_encoding': item['transcode_hw_encoding'],
'media_type': item['media_type'],
'title': item['title'],
'grandparent_title': item['grandparent_title']
'grandparent_title': item['grandparent_title'],
'current_session': 1 if session_key else 0
}
stream_output = {k: v or '' for k, v in stream_output.iteritems()}
return stream_output
def get_metadata_details(self, rating_key):

View File

@@ -131,19 +131,19 @@ def notify_conditions(notify_action=None, stream_data=None, timeline_data=None):
if stream_data:
# Check if notifications enabled for user and library
user_data = users.Users()
user_details = user_data.get_details(user_id=stream_data['user_id'])
# user_data = users.Users()
# user_details = user_data.get_details(user_id=stream_data['user_id'])
#
# library_data = libraries.Libraries()
# library_details = library_data.get_details(section_id=stream_data['section_id'])
library_data = libraries.Libraries()
library_details = library_data.get_details(section_id=stream_data['section_id'])
if not user_details['do_notify']:
logger.debug(u"Tautulli NotificationHandler :: Notifications for user '%s' are disabled." % user_details['username'])
return False
elif not library_details['do_notify'] and notify_action not in ('on_concurrent', 'on_newdevice'):
logger.debug(u"Tautulli NotificationHandler :: Notifications for library '%s' are disabled." % library_details['section_name'])
return False
# if not user_details['do_notify']:
# logger.debug(u"Tautulli NotificationHandler :: Notifications for user '%s' are disabled." % user_details['username'])
# return False
#
# elif not library_details['do_notify'] and notify_action not in ('on_concurrent', 'on_newdevice'):
# logger.debug(u"Tautulli NotificationHandler :: Notifications for library '%s' are disabled." % library_details['section_name'])
# return False
if notify_action == 'on_concurrent':
pms_connect = pmsconnect.PmsConnect()
@@ -188,12 +188,12 @@ def notify_conditions(notify_action=None, stream_data=None, timeline_data=None):
elif timeline_data:
# Check if notifications enabled for library
library_data = libraries.Libraries()
library_details = library_data.get_details(section_id=timeline_data['section_id'])
if not library_details['do_notify_created']:
# logger.debug(u"Tautulli NotificationHandler :: Notifications for library '%s' is disabled." % library_details['section_name'])
return False
# library_data = libraries.Libraries()
# library_details = library_data.get_details(section_id=timeline_data['section_id'])
#
# if not library_details['do_notify_created']:
# # logger.debug(u"Tautulli NotificationHandler :: Notifications for library '%s' is disabled." % library_details['section_name'])
# return False
return True
@@ -206,19 +206,21 @@ def notify_custom_conditions(notifier_id=None, parameters=None):
notifier_config = notifiers.get_notifier_config(notifier_id=notifier_id)
custom_conditions_logic = notifier_config['custom_conditions_logic']
custom_conditions = json.loads(notifier_config['custom_conditions']) or []
if custom_conditions_logic:
logger.debug(u"Tautulli NotificationHandler :: Checking custom notification conditions for notifier_id %s." % notifier_id)
if custom_conditions_logic or any(c for c in custom_conditions if c['value']):
logger.debug(u"Tautulli NotificationHandler :: Checking custom notification conditions for notifier_id %s."
% notifier_id)
custom_conditions = json.loads(notifier_config['custom_conditions'])
try:
# Parse and validate the custom conditions logic
logic_groups = helpers.parse_condition_logic_string(custom_conditions_logic, len(custom_conditions))
except ValueError as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom condition logic '%s': %s."
% (custom_conditions_logic, e))
return False
logic_groups = None
if custom_conditions_logic:
try:
# Parse and validate the custom conditions logic
logic_groups = helpers.parse_condition_logic_string(custom_conditions_logic, len(custom_conditions))
except ValueError as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom condition logic '%s': %s."
% (custom_conditions_logic, e))
return False
evaluated_conditions = [None] # Set condition {0} to None
@@ -227,10 +229,11 @@ def notify_custom_conditions(notifier_id=None, parameters=None):
operator = condition['operator']
values = condition['value']
parameter_type = condition['type']
parameter_value = parameters.get(parameter, "")
# Set blank conditions to None
# Set blank conditions to True (skip)
if not parameter or not operator or not values:
evaluated_conditions.append(None)
evaluated_conditions.append(True)
continue
# Make sure the condition values is in a list
@@ -248,25 +251,25 @@ def notify_custom_conditions(notifier_id=None, parameters=None):
elif parameter_type == 'float':
values = [float(v) for v in values]
except Exception as e:
logger.error(u"Tautulli NotificationHandler :: Unable to cast condition '%s' to type '%s'."
% (parameter, parameter_type))
except ValueError as e:
logger.error(u"Tautulli NotificationHandler :: Unable to cast condition '%s', values '%s', to type '%s'."
% (parameter, values, parameter_type))
return False
# Cast the parameter value to the correct type
try:
if parameter_type == 'str':
parameter_value = unicode(parameters[parameter]).lower()
parameter_value = unicode(parameter_value).lower()
elif parameter_type == 'int':
parameter_value = int(parameters[parameter])
parameter_value = int(parameter_value)
elif parameter_type == 'float':
parameter_value = float(parameters[parameter])
except Exception as e:
logger.error(u"Tautulli NotificationHandler :: Unable to cast parameter '%s' to type '%s'."
% (parameter, parameter_type))
parameter_value = float(parameter_value)
except ValueError as e:
logger.error(u"Tautulli NotificationHandler :: Unable to cast parameter '%s', value '%s', to type '%s'."
% (parameter, parameter_value, parameter_type))
return False
# Check each condition
@@ -298,12 +301,15 @@ def notify_custom_conditions(notifier_id=None, parameters=None):
logger.warn(u"Tautulli NotificationHandler :: Invalid condition operator '%s'." % operator)
evaluated_conditions.append(None)
# Format and evaluate the logic string
try:
evaluated_logic = helpers.eval_logic_groups_to_bool(logic_groups, evaluated_conditions)
except Exception as e:
logger.error(u"Tautulli NotificationHandler :: Unable to evaluate custom condition logic: %s." % e)
return False
if logic_groups:
# Format and evaluate the logic string
try:
evaluated_logic = helpers.eval_logic_groups_to_bool(logic_groups, evaluated_conditions)
except Exception as e:
logger.error(u"Tautulli NotificationHandler :: Unable to evaluate custom condition logic: %s." % e)
return False
else:
evaluated_logic = all(evaluated_conditions[1:])
logger.debug(u"Tautulli NotificationHandler :: Custom condition evaluated to '%s'." % str(evaluated_logic))
return evaluated_logic
@@ -326,7 +332,7 @@ def notify(notifier_id=None, notify_action=None, stream_data=None, timeline_data
if not notifier_config:
return
if notify_action == 'test':
if notify_action in ('test', 'api'):
subject = kwargs.pop('subject', 'Tautulli')
body = kwargs.pop('body', 'Test Notification')
script_args = kwargs.pop('script_args', [])
@@ -344,8 +350,8 @@ def notify(notifier_id=None, notify_action=None, stream_data=None, timeline_data
# Set the notification state in the db
notification_id = set_notify_state(session=stream_data or timeline_data,
notify_action=notify_action,
notifier=notifier_config,
notify_action=notify_action,
subject=subject,
body=body,
script_args=script_args)
@@ -384,9 +390,9 @@ def get_notify_state(session):
return notify_states
def set_notify_state(notify_action, notifier, subject, body, script_args, session=None):
def set_notify_state(notifier, notify_action, subject='', body='', script_args='', session=None):
if notify_action and notifier:
if notifier and notify_action:
monitor_db = database.MonitorDatabase()
session = session or {}
@@ -451,7 +457,11 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
notify_params = defaultdict(str)
if session:
# Reload json from raw stream info
if session.get('raw_stream_info'):
session.update(json.loads(session['raw_stream_info']))
notify_params.update(session)
if timeline:
notify_params.update(timeline)
@@ -513,7 +523,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
remaining_duration = duration - view_offset
# Build Plex URL
notify_params['plex_url'] = '{web_url}#!/server/{pms_identifier}/details?key=%2Flibrary%2Fnotify_params%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,
pms_identifier=plexpy.CONFIG.PMS_IDENTIFIER,
rating_key=rating_key)
@@ -525,7 +535,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']
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['trakt_url'] = 'https://trakt.tv/search/tvdb/' + notify_params['thetvdb_id'] + '?id_type=show'
@@ -622,8 +632,8 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
artist_name = notify_params['parent_title']
album_name = notify_params['title']
track_name = ''
season_num = notify_params['media_index'].zfill(1)
season_num00 = notify_params['media_index'].zfill(2)
season_num = str(notify_params['media_index']).zfill(1)
season_num00 = str(notify_params['media_index']).zfill(2)
num, num00 = format_group_index([helpers.cast_to_int(d['media_index'])
for d in child_metadata if d['parent_rating_key'] == rating_key])
@@ -636,12 +646,12 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
artist_name = notify_params['grandparent_title']
album_name = notify_params['parent_title']
track_name = notify_params['title']
season_num = notify_params['parent_media_index'].zfill(1)
season_num00 = notify_params['parent_media_index'].zfill(2)
episode_num = notify_params['media_index'].zfill(1)
episode_num00 = notify_params['media_index'].zfill(2)
track_num = notify_params['media_index'].zfill(1)
track_num00 = notify_params['media_index'].zfill(2)
season_num = str(notify_params['parent_media_index']).zfill(1)
season_num00 = str(notify_params['parent_media_index']).zfill(2)
episode_num = str(notify_params['media_index']).zfill(1)
episode_num00 = str(notify_params['media_index']).zfill(2)
track_num = str(notify_params['media_index']).zfill(1)
track_num00 = str(notify_params['media_index']).zfill(2)
available_params = {
# Global paramaters
@@ -651,7 +661,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'server_name': server_name,
'server_uptime': server_uptime,
'server_version': server_times.get('version', ''),
'action': notify_action.split('on_')[-1],
'action': notify_action.lstrip('on_'),
'datestamp': arrow.now().format(date_format),
'timestamp': arrow.now().format(time_format),
# Stream parameters
@@ -855,7 +865,7 @@ def build_server_notify_params(notify_action=None, **kwargs):
'server_name': server_name,
'server_uptime': server_uptime,
'server_version': server_times.get('version', ''),
'action': notify_action.split('on_')[-1],
'action': notify_action.lstrip('on_'),
'datestamp': arrow.now().format(date_format),
'timestamp': arrow.now().format(time_format),
# Plex Media Server update parameters
@@ -937,7 +947,7 @@ def build_notify_text(subject='', body='', notify_action=None, parameters=None,
try:
script_args = [custom_formatter.format(unicode(arg), **parameters) for arg in subject.split()]
except LookupError as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse field %s in script argument. Using fallback." % e)
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in script argument. Using fallback." % e)
script_args = []
except Exception as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom script arguments: %s. Using fallback." % e)
@@ -948,7 +958,7 @@ def build_notify_text(subject='', body='', notify_action=None, parameters=None,
try:
subject = custom_formatter.format(unicode(subject), **parameters)
except LookupError as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse field %s in notification subject. Using fallback." % e)
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in notification subject. Using fallback." % e)
subject = unicode(default_subject).format(**parameters)
except Exception as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom notification subject: %s. Using fallback." % e)
@@ -957,7 +967,7 @@ def build_notify_text(subject='', body='', notify_action=None, parameters=None,
try:
body = custom_formatter.format(unicode(body), **parameters)
except LookupError as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse field %s in notification body. Using fallback." % e)
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in notification body. Using fallback." % e)
body = unicode(default_body).format(**parameters)
except Exception as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom notification body: %s. Using fallback." % e)

View File

@@ -60,8 +60,8 @@ import logger
import mobile_app
import pmsconnect
import request
import users
from plexpy.config import _BLACKLIST_KEYS, _WHITELIST_KEYS
from plexpy.helpers import checked
AGENT_IDS = {'growl': 0,
@@ -624,9 +624,9 @@ class PrettyMetadata(object):
poster_url = self.parameters['poster_url']
if not poster_url:
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:
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
def get_provider_name(self, provider):
@@ -715,6 +715,17 @@ class Notifier(object):
return new_config
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
def make_request(self, url, method='POST', **kwargs):
@@ -755,10 +766,7 @@ class ANDROIDAPP(Notifier):
_ONESIGNAL_APP_ID = '3b4b666a-d557-4b92-acdf-e2c8c4b95357'
def notify(self, subject='', body='', action='', notification_id=None, **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', notification_id=None, **kwargs):
# Check mobile device is still registered
device = mobile_app.get_mobile_devices(device_id=self.config['device_id'])
if not device:
@@ -919,10 +927,7 @@ class BOXCAR(Notifier):
'sound': ''
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'user_credentials': self.config['token'],
'notification[title]': subject.encode('utf-8'),
'notification[long_message]': body.encode('utf-8'),
@@ -993,10 +998,7 @@ class BROWSER(Notifier):
'auto_hide_delay': 5
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
logger.info(u"Tautulli Notifiers :: {name} notification sent.".format(name=self.NAME))
return True
@@ -1063,10 +1065,7 @@ class DISCORD(Notifier):
'music_provider': ''
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
if self.config['incl_subject']:
text = subject.encode('utf-8') + '\r\n' + body.encode("utf-8")
else:
@@ -1179,7 +1178,8 @@ class DISCORD(Notifier):
{'label': 'Include Rich Metadata Info',
'value': self.config['incl_card'],
'name': 'discord_incl_card',
'description': 'Include an info card with a poster and metadata with the notifications.',
'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'
},
{'label': 'Include Plot Summaries',
@@ -1203,16 +1203,16 @@ class DISCORD(Notifier):
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'discord_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank for default.<br> \
3rd party API lookup may need to be enabled under the notification settings tab.',
'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': 'discord_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 notification settings tab.',
'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()
},
@@ -1246,10 +1246,17 @@ class EMAIL(Notifier):
'html_support': 1
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def __init__(self, config=None):
super(EMAIL, self).__init__(config=config)
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']:
body = body.replace('\n', '<br />')
msg = MIMEMultipart('alternative')
@@ -1260,13 +1267,10 @@ class EMAIL(Notifier):
msg['Subject'] = subject
msg['From'] = email.utils.formataddr((self.config['from_name'], self.config['from']))
msg['To'] = self.config['to']
msg['CC'] = self.config['cc']
msg['To'] = ','.join(self.config['to'])
msg['CC'] = ','.join(self.config['cc'])
recipients = [x.strip() for x in self.config['to'].split(';')] \
+ [x.strip() for x in self.config['cc'].split(';')] \
+ [x.strip() for x in self.config['bcc'].split(';')]
recipients = filter(None, recipients)
recipients = self.config['to'] + self.config['cc'] + self.config['bcc']
try:
mailserver = smtplib.SMTP(self.config['smtp_server'], self.config['smtp_port'])
@@ -1290,6 +1294,8 @@ class EMAIL(Notifier):
return False
def return_config_options(self):
user_emails = {} # User selection set with selectize options
config_option = [{'label': 'From Name',
'value': self.config['from_name'],
'name': 'email_from_name',
@@ -1305,20 +1311,23 @@ class EMAIL(Notifier):
{'label': 'To',
'value': self.config['to'],
'name': 'email_to',
'description': 'The email address(es) of the recipients, separated by semicolons (;).',
'input_type': 'text'
'description': 'The email address(es) of the recipients.',
'input_type': 'select',
'select_options': user_emails
},
{'label': 'CC',
'value': self.config['cc'],
'name': 'email_cc',
'description': 'The email address(es) to CC, separated by semicolons (;).',
'input_type': 'text'
'description': 'The email address(es) to CC.',
'input_type': 'select',
'select_options': user_emails
},
{'label': 'BCC',
'value': self.config['bcc'],
'name': 'email_bcc',
'description': 'The email address(es) to BCC, separated by semicolons (;).',
'input_type': 'text'
'description': 'The email address(es) to BCC.',
'input_type': 'select',
'select_options': user_emails
},
{'label': 'SMTP Server',
'value': self.config['smtp_server'],
@@ -1353,7 +1362,7 @@ class EMAIL(Notifier):
{'label': 'Enable HTML Support',
'value': self.config['html_support'],
'name': 'email_html_support',
'description': 'Style your messages using HTML tags. '
'description': 'Style your messages using HTML tags. '
'Line breaks (&lt;br&gt;) will be inserted automatically.',
'input_type': 'checkbox'
}
@@ -1416,7 +1425,7 @@ class FACEBOOK(Notifier):
except Exception as e:
logger.error(u"Tautulli Notifiers :: Error requesting {name} access token: {e}".format(name=self.NAME, e=e))
plexpy.CONFIG.FACEBOOK_TOKEN = ''
# Clear out temporary config values
plexpy.CONFIG.FACEBOOK_APP_ID = ''
plexpy.CONFIG.FACEBOOK_APP_SECRET = ''
@@ -1440,10 +1449,7 @@ class FACEBOOK(Notifier):
logger.error(u"Tautulli Notifiers :: Error sending {name} post: No {name} Group ID provided.".format(name=self.NAME))
return False
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
if self.config['incl_subject']:
text = subject.encode('utf-8') + '\r\n' + body.encode("utf-8")
else:
@@ -1463,24 +1469,24 @@ class FACEBOOK(Notifier):
provider = self.config['music_provider']
else:
provider = None
data['link'] = pretty_metadata.get_provider_link(provider)
return self._post_facebook(**data)
def return_config_options(self):
config_option = [{'label': 'Instructions',
'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>\
Step 2: Click <strong>Add Product</strong> on the left, then <strong>Get Started</strong> \
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 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 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 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.',
'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>'
'Step 2: Click <strong>Add Product</strong> on the left, then <strong>Get Started</strong>'
'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 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 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 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.',
'input_type': 'help'
},
{'label': 'Tautulli URL',
@@ -1529,22 +1535,23 @@ class FACEBOOK(Notifier):
{'label': 'Include Rich Metadata Info',
'value': self.config['incl_card'],
'name': 'facebook_incl_card',
'description': 'Include an info card with a poster and metadata with the notifications.',
'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'
},
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'facebook_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank for default.<br> \
3rd party API lookup may need to be enabled under the notification settings tab.',
'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': 'facebook_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 notification settings tab.',
'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()
},
@@ -1571,10 +1578,7 @@ class GROUPME(Notifier):
'incl_poster': 0
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'bot_id': self.config['bot_id']}
if self.config['incl_subject']:
@@ -1649,10 +1653,7 @@ class GROWL(Notifier):
'password': ''
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
# Split host and port
if self.config['host'] == "":
host, port = "localhost", 23053
@@ -1692,7 +1693,7 @@ class GROWL(Notifier):
# Send it, including an image
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:
image = f.read()
@@ -1745,10 +1746,7 @@ class HIPCHAT(Notifier):
'music_provider': ''
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'notify': 'false'}
text = body.encode('utf-8')
@@ -1858,6 +1856,7 @@ class HIPCHAT(Notifier):
'value': self.config['incl_card'],
'name': 'hipchat_incl_card',
'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.',
'input_type': 'checkbox'
},
@@ -1876,16 +1875,16 @@ class HIPCHAT(Notifier):
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'hipchat_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 notification settings tab.',
'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': 'hipchat_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 notification settings tab.',
'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()
},
@@ -1910,10 +1909,7 @@ class IFTTT(Notifier):
'event': 'plexpy'
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
event = unicode(self.config['event']).format(action=action)
data = {'value1': subject.encode("utf-8"),
@@ -1953,23 +1949,50 @@ class JOIN(Notifier):
"""
NAME = 'Join'
_DEFAULT_CONFIG = {'api_key': '',
'device_id': '',
'incl_subject': 1
'device_names': '',
'priority': 2,
'incl_subject': 1,
'incl_poster': 0,
'movie_provider': '',
'tv_provider': '',
'music_provider': ''
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def __init__(self, config=None):
super(JOIN, self).__init__(config=config)
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'],
deviceid_key: self.config['device_id'],
'deviceNames': ','.join(self.config['device_names']),
'text': body.encode("utf-8")}
if self.config['incl_subject']:
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)
if r.status_code == 200:
@@ -1987,6 +2010,9 @@ class JOIN(Notifier):
return False
def get_devices(self):
devices = {d: d for d in self.config['device_names']}
devices.update({'': ''})
if self.config['api_key']:
params = {'apikey': self.config['api_key']}
@@ -1995,28 +2021,22 @@ class JOIN(Notifier):
if r.status_code == 200:
response_data = r.json()
if response_data.get('success'):
devices = response_data.get('records', [])
devices = {d['deviceId']: d['deviceName'] for d in devices}
devices.update({'': ''})
response_devices = response_data.get('records', [])
devices.update({d['deviceName']: d['deviceName'] for d in response_devices})
return devices
else:
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))
return {'': ''}
return devices
else:
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)))
return {'': ''}
return devices
else:
return {'': ''}
return devices
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',
'value': self.config['api_key'],
'name': 'join_api_key',
@@ -2024,22 +2044,55 @@ class JOIN(Notifier):
'input_type': 'text',
'refresh': True
},
{'label': 'Device ID(s) or Group ID',
'value': self.config['device_id'],
'name': 'join_device_id',
'description': 'Set your Join device ID or group ID. ' \
'Separate multiple devices with commas (,).',
'input_type': 'text',
{'label': 'Device Name(s)',
'value': self.config['device_names'],
'name': 'join_device_names',
'description': 'Select your Join device(s).',
'input_type': 'select',
'select_options': self.get_devices()
},
{'label': 'Your Devices IDs',
'description': devices,
'input_type': 'help'
{'label': 'Priority',
'value': self.config['priority'],
'name': 'join_priority',
'description': 'Set the notification priority.',
'input_type': 'select',
'select_options': {-2: -2, -1: -1, 0: 0, 1: 1, 2: 2}
},
{'label': 'Include Subject Line',
'value': self.config['incl_subject'],
'name': 'join_incl_subject',
'description': 'Include the subject line with the notifications.',
'input_type': 'checkbox'
},
{'label': 'Include Poster Image',
'value': self.config['incl_poster'],
'name': 'join_incl_poster',
'description': 'Include a poster with the notifications.<br>'
'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()
}
]
@@ -2062,10 +2115,7 @@ class MQTT(Notifier):
'keep_alive': 60
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
if not self.config['topic']:
logger.error(u"Tautulli Notifiers :: MQTT topic not specified.")
return
@@ -2168,10 +2218,7 @@ class NMA(Notifier):
'priority': 0
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
title = 'Tautulli'
batch = False
@@ -2248,7 +2295,7 @@ class OSX(Notifier):
def _swizzled_bundleIdentifier(self, original, swizzled):
return 'ade.plexpy.osxnotify'
def notify(self, subject='', body='', action='', **kwargs):
def agent_notify(self, subject='', body='', action='', **kwargs):
subtitle = kwargs.get('subtitle', '')
sound = kwargs.get('sound', '')
@@ -2341,10 +2388,7 @@ class PLEX(Notifier):
if response:
return response[0]['result']
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
hosts = [x.strip() for x in self.config['hosts'].split(',')]
if self.config['display_time'] > 0:
@@ -2355,7 +2399,7 @@ class PLEX(Notifier):
if self.config['image']:
image = self.config['image']
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:
logger.info(u"Tautulli Notifiers :: Sending notification command to {name} @ {host}".format(name=self.NAME, host=host))
@@ -2379,7 +2423,7 @@ class PLEX(Notifier):
except Exception as e:
logger.error(u"Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e))
return False
return True
def return_config_options(self):
@@ -2427,16 +2471,13 @@ class PROWL(Notifier):
'priority': 0
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'apikey': self.config['key'],
'application': 'Tautulli',
'event': subject.encode("utf-8"),
'description': body.encode("utf-8"),
'priority': self.config['priority']}
headers = {'Content-type': 'application/x-www-form-urlencoded'}
return self.make_request('https://api.prowlapp.com/publicapi/add', headers=headers, data=data)
@@ -2468,10 +2509,7 @@ class PUSHALOT(Notifier):
_DEFAULT_CONFIG = {'api_key': ''
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'AuthorizationToken': self.config['api_key'],
'Title': subject.encode('utf-8'),
'Body': body.encode("utf-8")}
@@ -2502,10 +2540,7 @@ class PUSHBULLET(Notifier):
'channel_tag': ''
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'type': 'note',
'title': subject.encode("utf-8"),
'body': body.encode("utf-8")}
@@ -2587,10 +2622,7 @@ class PUSHOVER(Notifier):
'music_provider': ''
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'token': self.config['api_token'],
'user': self.config['key'],
'title': subject.encode("utf-8"),
@@ -2684,16 +2716,16 @@ class PUSHOVER(Notifier):
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'pushover_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 notification settings tab.',
'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': 'pushover_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 notification settings tab.',
'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()
},
@@ -2795,7 +2827,7 @@ class SCRIPTS(Notifier):
logger.info(u"Tautulli Notifiers :: Script notification sent.")
return True
def notify(self, subject='', body='', action='', **kwargs):
def agent_notify(self, subject='', body='', action='', **kwargs):
"""
Args:
subject(string, optional): Subject text,
@@ -2907,10 +2939,7 @@ class SLACK(Notifier):
'music_provider': ''
}
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
if self.config['incl_subject']:
text = subject.encode('utf-8') + '\r\n' + body.encode("utf-8")
else:
@@ -3022,7 +3051,8 @@ class SLACK(Notifier):
{'label': 'Include Rich Metadata Info',
'value': self.config['incl_card'],
'name': 'slack_incl_card',
'description': 'Include an info card with a poster and metadata with the notifications.',
'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'
},
{'label': 'Include Plot Summaries',
@@ -3046,16 +3076,16 @@ class SLACK(Notifier):
{'label': 'Movie Link Source',
'value': self.config['movie_provider'],
'name': 'slack_movie_provider',
'description': 'Select the source for movie links on the info cards. Leave blank for default.<br> \
3rd party API lookup may need to be enabled under the notification settings tab.',
'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': 'slack_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 notification settings tab.',
'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()
},
@@ -3084,10 +3114,7 @@ class TELEGRAM(Notifier):
'incl_poster': 0
}
def notify(self, subject='', body='', action='', **kwargs):
if not body or not subject:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
data = {'chat_id': self.config['chat_id']}
if self.config['incl_subject']:
@@ -3150,7 +3177,8 @@ class TELEGRAM(Notifier):
{'label': 'Include Poster Image',
'value': self.config['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'
},
{'label': 'Enable HTML Support',
@@ -3205,10 +3233,7 @@ class TWITTER(Notifier):
logger.error(u"Tautulli Notifiers :: {name} notification failed: {e}".format(name=self.NAME, e=e))
return False
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
poster_url = ''
if self.config['incl_poster'] and kwargs.get('parameters'):
parameters = kwargs['parameters']
@@ -3221,12 +3246,12 @@ class TWITTER(Notifier):
def return_config_options(self):
config_option = [{'label': 'Instructions',
'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>\
Step 2: Go to <strong>Keys and Access Tokens</strong> and click \
<strong>Create my access token</strong>.<br>\
Step 3: Fill in the <strong>Consumer Key</strong>, <strong>Consumer Secret</strong>, \
<strong>Access Token</strong>, and <strong>Access Token Secret</strong> below.',
'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>'
'Step 2: Go to <strong>Keys and Access Tokens</strong> and click '
'<strong>Create my access token</strong>.<br>'
'Step 3: Fill in the <strong>Consumer Key</strong>, <strong>Consumer Secret</strong>, '
'<strong>Access Token</strong>, and <strong>Access Token Secret</strong> below.',
'input_type': 'help'
},
{'label': 'Twitter Consumer Key',
@@ -3262,7 +3287,8 @@ class TWITTER(Notifier):
{'label': 'Include Poster Image',
'value': self.config['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'
}
]
@@ -3305,10 +3331,7 @@ class XBMC(Notifier):
if response:
return response[0]['result']
def notify(self, subject='', body='', action='', **kwargs):
if not subject or not body:
return
def agent_notify(self, subject='', body='', action='', **kwargs):
hosts = [x.strip() for x in self.config['hosts'].split(',')]
if self.config['display_time'] > 0:
@@ -3319,7 +3342,7 @@ class XBMC(Notifier):
if self.config['image']:
image = self.config['image']
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:
logger.info(u"Tautulli Notifiers :: Sending notification command to XMBC @ " + host)

View File

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

View File

@@ -542,8 +542,8 @@ class PmsConnect(object):
if metadata:
_cache_time = metadata.pop('_cache_time', 0)
# Return cached metadata if less than 30 minutes ago
if int(time.time()) - _cache_time <= 1800:
# Return cached metadata if less than METADATA_CACHE_SECONDS ago
if int(time.time()) - _cache_time <= plexpy.CONFIG.METADATA_CACHE_SECONDS:
return metadata
if rating_key:
@@ -1155,9 +1155,9 @@ class PmsConnect(object):
metadata['media_info'] = medias
if metadata:
metadata['_cache_time'] = int(time.time())
if cache_key:
metadata['_cache_time'] = int(time.time())
out_file_path = os.path.join(plexpy.CONFIG.CACHE_DIR, 'metadata-sessionKey-%s.json' % cache_key)
try:
with open(out_file_path, 'w') as outFile:

View File

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

View File

@@ -52,6 +52,7 @@ def refresh_users():
new_value_dict = {"username": item['username'],
"thumb": item['thumb'],
"email": item['email'],
"is_admin": item['is_admin'],
"is_home_user": item['is_home_user'],
"is_allow_sync": item['is_allow_sync'],
"is_restricted": item['is_restricted'],
@@ -330,6 +331,7 @@ class Users(object):
'friendly_name': 'Local',
'user_thumb': common.DEFAULT_USER_THUMB,
'email': '',
'is_admin': '',
'is_home_user': 0,
'is_allow_sync': 0,
'is_restricted': 0,
@@ -349,21 +351,21 @@ class Users(object):
try:
if str(user_id).isdigit():
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 ' \
'FROM users ' \
'WHERE user_id = ? '
result = monitor_db.select(query, args=[user_id])
elif user:
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 ' \
'FROM users ' \
'WHERE username = ? COLLATE NOCASE '
result = monitor_db.select(query, args=[user])
elif email:
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 ' \
'FROM users ' \
'WHERE email = ? COLLATE NOCASE '
@@ -398,6 +400,7 @@ class Users(object):
'friendly_name': friendly_name,
'user_thumb': user_thumb,
'email': item['email'],
'is_admin': item['is_admin'],
'is_home_user': item['is_home_user'],
'is_allow_sync': item['is_allow_sync'],
'is_restricted': item['is_restricted'],
@@ -580,6 +583,27 @@ class Users(object):
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'],
'email': item['email']
}
users.append(user)
return users
def delete_all_history(self, user_id=None):
monitor_db = database.MonitorDatabase()

View File

@@ -1,2 +1,2 @@
PLEXPY_BRANCH = "beta"
PLEXPY_RELEASE_VERSION = "v2.0.8-beta"
PLEXPY_RELEASE_VERSION = "v2.0.13-beta"

View File

@@ -18,12 +18,12 @@
# Form based authentication for CherryPy. Requires the
# Session tool to be loaded.
from cgi import escape
from datetime import datetime, timedelta
import re
import cherrypy
from hashing_passwords import check_hash
import jwt
import plexpy
import logger
@@ -32,7 +32,9 @@ from plexpy.users import Users, refresh_users
from plexpy.plextv import PlexTV
SESSION_KEY = '_cp_username'
JWT_ALGORITHM = 'HS256'
JWT_COOKIE_NAME = 'tautulli_token_'
def user_login(username=None, password=None):
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']):
# The user is not in the database.
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']:
# Guest access is disabled or the user is deleted.
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.
# If a server token is returned, then the user is a valid friend of the server.
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_users()
# Successful login
return True
return 'guest'
else:
logger.warn(u"Tautulli WebAuth :: Unable to register user '%s' in database." % username)
return None
@@ -89,37 +98,62 @@ def user_login(username=None, password=None):
return None
def check_credentials(username, password, admin_login='0'):
"""Verifies credentials for username and password.
Returns True and the user group on success or False and no user group"""
if plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD):
return True, u'admin'
elif username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD:
return True, u'admin'
elif not admin_login == '1' and plexpy.CONFIG.ALLOW_GUEST_ACCESS and user_login(username, password):
return True, u'guest'
else:
return False, None
if plexpy.CONFIG.HTTP_PASSWORD:
if plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
username == plexpy.CONFIG.HTTP_USERNAME and check_hash(password, plexpy.CONFIG.HTTP_PASSWORD):
return True, 'admin'
elif not plexpy.CONFIG.HTTP_HASHED_PASSWORD and \
username == plexpy.CONFIG.HTTP_USERNAME and password == plexpy.CONFIG.HTTP_PASSWORD:
return True, 'admin'
if plexpy.CONFIG.HTTP_PLEX_ADMIN or (not admin_login == '1' and plexpy.CONFIG.ALLOW_GUEST_ACCESS):
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):
"""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
conditions that the user must fulfill"""
conditions = cherrypy.request.config.get('auth.require', 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:
# A condition is just a callable that returns true or false
if not condition():
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
else:
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/logout")
def requireAuth(*conditions):
"""A decorator that appends conditions to the auth.require config
variable."""
@@ -140,14 +174,13 @@ def requireAuth(*conditions):
#
# Define those at will however suits the application.
def member_of(groupname):
def check():
# replace with actual check if <username> is in <groupname>
return cherrypy.request.login == plexpy.CONFIG.HTTP_USERNAME and groupname == 'admin'
return check
def member_of(user_group):
return lambda: cherrypy.request.login and cherrypy.request.login['user_group'] == user_group
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
@@ -160,6 +193,7 @@ def any_of(*conditions):
return False
return check
# By default all conditions are required, but this might still be
# needed if you want to use it inside of an any_of(...) condition
def all_of(*conditions):
@@ -175,7 +209,12 @@ def all_of(*conditions):
# Controller to provide login and logout actions
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):
"""Called on successful login"""
@@ -196,7 +235,7 @@ class AuthController(object):
def on_logout(self, username, user_group):
"""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):
"""Called on failed login"""
@@ -212,25 +251,48 @@ class AuthController(object):
user_agent=user_agent,
success=0)
def get_loginform(self, username="", msg=""):
def get_loginform(self):
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
def index(self):
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT + "auth/login")
@cherrypy.expose
def login(self, username=None, password=None, remember_me='0', admin_login='0'):
if not cherrypy.config.get('tools.sessions.on'):
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
def login(self):
self.check_auth_enabled()
if not username and not password:
return self.get_loginform()
(vaild_login, user_group) = check_credentials(username, password, admin_login)
return self.get_loginform()
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 re.match(r"[^@]+@[^@]+\.[^@]+", username):
user_details = Users().get_details(email=username)
@@ -241,35 +303,37 @@ class AuthController(object):
else:
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
cherrypy.session[SESSION_KEY] = {'user_id': user_id,
'user': username,
'user_group': user_group,
'expiry': expiry}
payload = {
'user_id': user_id,
'user': username,
'user_group': user_group,
'exp': expiry
}
jwt_token = jwt.encode(payload, plexpy.CONFIG.JWT_SECRET, algorithm=JWT_ALGORITHM)
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':
self.on_login_failed(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:
self.on_login_failed(username)
logger.debug(u"Tautulli WebAuth :: Invalid login attempt from '%s'." % username)
return self.get_loginform(username, u"Incorrect username/email or password.")
@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")
cherrypy.response.status = 401
return error_message

View File

@@ -2538,6 +2538,7 @@ class WebInterface(object):
"http_password": http_password,
"http_root": plexpy.CONFIG.HTTP_ROOT,
"http_proxy": checked(plexpy.CONFIG.HTTP_PROXY),
"http_plex_admin": checked(plexpy.CONFIG.HTTP_PLEX_ADMIN),
"launch_browser": checked(plexpy.CONFIG.LAUNCH_BROWSER),
"enable_https": checked(plexpy.CONFIG.ENABLE_HTTPS),
"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",
"allow_guest_access", "cache_images", "http_proxy", "http_basic_auth", "notify_concurrent_by_ip",
"history_table_activity", "plexpy_auto_update",
"themoviedb_lookup", "tvmaze_lookup"
"themoviedb_lookup", "tvmaze_lookup", "http_plex_admin"
]
for checked_config in checked_configs:
if checked_config not in kwargs:
@@ -2673,8 +2674,7 @@ class WebInterface(object):
refresh_users = False
# First run from the setup wizard
if kwargs.get('first_run'):
del kwargs['first_run']
if kwargs.pop('first_run', None):
first_run = True
# If we change any monitoring settings, make sure we reschedule tasks.
@@ -2728,12 +2728,15 @@ class WebInterface(object):
refresh_libraries = True
# If we change the server, make sure we grab the new url and refresh libraries and users lists.
if kwargs.get('server_changed'):
del kwargs['server_changed']
if kwargs.pop('server_changed', None):
server_changed = True
refresh_users = 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)
# Write the config
@@ -3079,7 +3082,6 @@ class WebInterface(object):
@cherrypy.expose
@requireAuth(member_of("admin"))
@addtoapi("notify")
def send_notification(self, notifier_id=None, subject='Tautulli', body='Test notification', notify_action='', **kwargs):
""" Send a notification using Tautulli.
@@ -4440,7 +4442,9 @@ class WebInterface(object):
counts = {'stream_count_direct_play': 0,
'stream_count_direct_stream': 0,
'stream_count_transcode': 0,
'total_bandwidth': 0}
'total_bandwidth': 0,
'lan_bandwidth': 0,
'wan_bandwidth': 0}
for s in result['sessions']:
if s['transcode_decision'] == 'transcode':
@@ -4451,6 +4455,10 @@ class WebInterface(object):
counts['stream_count_direct_play'] += 1
counts['total_bandwidth'] += helpers.cast_to_int(s['bandwidth'])
if s['location'] == 'lan':
counts['lan_bandwidth'] += helpers.cast_to_int(s['bandwidth'])
elif s['location'] == 'wan':
counts['wan_bandwidth'] += helpers.cast_to_int(s['bandwidth'])
result.update(counts)

View File

@@ -35,7 +35,8 @@ def initialize(options):
if enable_https:
# If either the HTTPS certificate or key do not exist, try to make self-signed ones.
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):
logger.warn(u"Tautulli WebStart :: Unable to create certificate and key. Disabling HTTPS")
enable_https = False
@@ -67,16 +68,21 @@ def initialize(options):
protocol = "http"
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']:
session_enabled = auth_enabled = False
auth_enabled = False
basic_auth_enabled = True
else:
options_dict['tools.sessions.on'] = session_enabled = auth_enabled = True
auth_enabled = True
basic_auth_enabled = False
cherrypy.tools.auth = cherrypy.Tool('before_handler', webauth.check_auth)
else:
session_enabled = auth_enabled = basic_auth_enabled = False
auth_enabled = basic_auth_enabled = False
if 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',
'text/javascript', 'application/json',
'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_basic.on': basic_auth_enabled,
'tools.auth_basic.realm': 'Tautulli web server',