Compare commits

...

48 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
JonnyWong16
26b06e453d v2.0.8-beta 2018-01-03 16:08:21 -08:00
JonnyWong16
54ab646048 Don't line break product or player on activity cards 2018-01-03 16:02:22 -08:00
JonnyWong16
12c9aa3d6a Try caching metadata for sessions 2018-01-03 13:36:26 -08:00
JonnyWong16
1ae8544f2d Cleanup notification parameters 2018-01-03 11:36:49 -08:00
JonnyWong16
eae9e66c75 Updating missing notification parameters 2018-01-02 16:18:50 -08:00
JonnyWong16
ad041a1691 Attempt to fix HW transcoding indicator 2018-01-02 16:13:27 -08:00
JonnyWong16
1aee3b6c8f Add idna 2.6 2018-01-02 09:03:55 -08:00
55 changed files with 12474 additions and 978 deletions

View File

@@ -1,5 +1,62 @@
# 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: 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.
## v2.0.7-beta (2018-01-01)
* Monitoring:

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;
@@ -844,6 +900,18 @@ a .users-poster-face:hover {
-webkit-flex-grow: 1;
flex-grow: 1;
}
.dashboard-activity-info-item .sub-value.platform-right {
margin-right: 55px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.dashboard-activity-info-item .sub-value.time-right {
margin-right: 60px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.dashboard-activity-info-item .sub-value .ip-container {
display: inline-flex;
}
@@ -1261,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 {
@@ -3710,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

@@ -64,6 +64,7 @@ DOCUMENTATION :: END
from collections import defaultdict
from urllib import quote
from plexpy import helpers
from plexpy.common import VIDEO_RESOLUTION_OVERRIDES, AUDIO_CODEC_OVERRIDES
import plexpy
%>
<% data = defaultdict(lambda: 'Unknown', **session) %>
@@ -134,15 +135,15 @@ DOCUMENTATION :: END
<ul class="list-unstyled dashboard-activity-info-list">
<li class="dashboard-activity-info-item">
<div class="sub-heading">Product</div>
<div class="sub-value">${data['product']}</div>
<div class="sub-value platform-right">${data['product']}</div>
</li>
<li class="dashboard-activity-info-item">
<div class="sub-heading">Player</div>
<div class="sub-value">${data['player']}</div>
<div class="sub-value platform-right">${data['player']}</div>
</li>
<li class="dashboard-activity-info-item">
<div class="sub-heading">Quality</div>
<div class="sub-value" id="stream_quality-${sk}">
<div class="sub-value platform-right" id="stream_quality-${sk}">
% if data['media_type'] != 'photo' and data['quality_profile'] != 'Unknown':
<%
br = helpers.cast_to_int(data['stream_bitrate']) or ''
@@ -214,17 +215,14 @@ DOCUMENTATION :: END
% if data['media_type'] in ('movie', 'episode', 'clip'):
% if data.get('stream_video_decision') == 'transcode':
<%
hw_d = hw_e = ''
if data['transcode_hw_requested'] == 1 and data['transcode_hw_full_pipeline'] == 0:
hw_d = ' (HW)'
elif data['transcode_hw_requested'] == 1 and data['transcode_hw_full_pipeline'] == 1:
hw_d = hw_e = ' (HW)'
hw_d = ' (HW)' if data['transcode_hw_decoding'] else ''
hw_e = ' (HW)' if data['transcode_hw_encoding'] else ''
%>
Transcode (${data['video_codec'].upper()}${hw_d} ${plexpy.common.VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])} &rarr; ${data['stream_video_codec'].upper()}${hw_e} ${plexpy.common.VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
Transcode (${data['video_codec'].upper()}${hw_d} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])} &rarr; ${data['stream_video_codec'].upper()}${hw_e} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
% elif data.get('stream_video_decision') == 'copy':
Direct Stream (${data['stream_video_codec'].upper()} ${plexpy.common.VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
Direct Stream (${data['stream_video_codec'].upper()} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
% else:
Direct Play (${data['video_codec'].upper()} ${plexpy.common.VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])})
Direct Play (${data['video_codec'].upper()} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])})
% endif
% elif data['media_type'] == 'photo':
Direct Play (${data['width']}x${data['height']})
@@ -237,11 +235,11 @@ DOCUMENTATION :: END
<div class="sub-heading">Audio</div>
<div class="sub-value" id="audio_decision-${sk}">
% if data.get('stream_audio_decision') == 'transcode':
Transcode (${plexpy.common.AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} &rarr; ${plexpy.common.AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
Transcode (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} &rarr; ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% elif data.get('stream_audio_decision') == 'copy':
Direct Stream (${plexpy.common.AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
Direct Stream (${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% else:
Direct Play (${plexpy.common.AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()})
Direct Play (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()})
% endif
</div>
</li>
@@ -270,7 +268,7 @@ DOCUMENTATION :: END
<ul class="list-unstyled dashboard-activity-info-list">
<li class="dashboard-activity-info-item">
<div class="sub-heading">Location</div>
<div class="sub-value">
<div class="sub-value time-right">
% if data['ip_address'] != 'N/A':
${data['location'].upper()}: <span class="ip-container"><span class="ip-address">${data['ip_address']}</span></span>
<a href="#" class="external_ip-modal" data-toggle="modal" data-target="#ip-info-modal" data-ip="${data['ip_address']}">
@@ -290,7 +288,7 @@ DOCUMENTATION :: END
</li>
<li class="dashboard-activity-info-item">
<div class="sub-heading">Bandwidth</div>
<div class="sub-value">
<div class="sub-value time-right">
% if data['media_type'] != 'photo' and helpers.cast_to_int(data['bandwidth']):
<%
bw = helpers.cast_to_int(data['bandwidth'])

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

@@ -39,7 +39,7 @@ DOCUMENTATION :: END
% if data:
<%
import plexpy
from plexpy.common import VIDEO_RESOLUTION_OVERRIDES, AUDIO_CODEC_OVERRIDES
%>
<div class="modal-dialog" role="document">
<div class="modal-content">
@@ -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>
@@ -85,8 +90,8 @@ DOCUMENTATION :: END
% if data['media_type'] != 'track':
<tr>
<td>Resolution</td>
<td>${plexpy.common.VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])}</td>
<td>${plexpy.common.VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])}</td>
<td>${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])}</td>
<td>${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])}</td>
</tr>
% endif
<tr>
@@ -124,8 +129,8 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Container</td>
<td>${data['stream_container']}</td>
<td>${data['container']}</td>
<td>${data['stream_container'].upper()}</td>
<td>${data['container'].upper()}</td>
</tr>
</tbody>
</table>
@@ -144,8 +149,8 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Codec</td>
<td>${data['stream_video_codec']}</td>
<td>${data['video_codec']}</td>
<td>${data['stream_video_codec'].upper()} ${'(HW)' if data['transcode_hw_encoding'] else ''}</td>
<td>${data['video_codec'].upper()} ${'(HW)' if data['transcode_hw_decoding'] else ''}</td>
</tr>
<tr>
<td>Bitrate</td>
@@ -189,8 +194,8 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Codec</td>
<td>${data['stream_audio_codec']}</td>
<td>${data['audio_codec']}</td>
<td>${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())}</td>
<td>${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())}</td>
</tr>
<tr>
<td>Bitrate</td>
@@ -219,8 +224,8 @@ DOCUMENTATION :: END
<tbody>
<tr>
<td>Codec</td>
<td>${data['stream_subtitle_codec']}</td>
<td>${data['subtitle_codec']}</td>
<td>${data['stream_subtitle_codec'].upper()}</td>
<td>${data['subtitle_codec'].upper()}</td>
</tr>
</tbody>
</table>

2
lib/idna/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .package_data import __version__
from .core import *

118
lib/idna/codec.py Normal file
View File

@@ -0,0 +1,118 @@
from .core import encode, decode, alabel, ulabel, IDNAError
import codecs
import re
_unicode_dots_re = re.compile(u'[\u002e\u3002\uff0e\uff61]')
class Codec(codecs.Codec):
def encode(self, data, errors='strict'):
if errors != 'strict':
raise IDNAError("Unsupported error handling \"{0}\"".format(errors))
if not data:
return "", 0
return encode(data), len(data)
def decode(self, data, errors='strict'):
if errors != 'strict':
raise IDNAError("Unsupported error handling \"{0}\"".format(errors))
if not data:
return u"", 0
return decode(data), len(data)
class IncrementalEncoder(codecs.BufferedIncrementalEncoder):
def _buffer_encode(self, data, errors, final):
if errors != 'strict':
raise IDNAError("Unsupported error handling \"{0}\"".format(errors))
if not data:
return ("", 0)
labels = _unicode_dots_re.split(data)
trailing_dot = u''
if labels:
if not labels[-1]:
trailing_dot = '.'
del labels[-1]
elif not final:
# Keep potentially unfinished label until the next call
del labels[-1]
if labels:
trailing_dot = '.'
result = []
size = 0
for label in labels:
result.append(alabel(label))
if size:
size += 1
size += len(label)
# Join with U+002E
result = ".".join(result) + trailing_dot
size += len(trailing_dot)
return (result, size)
class IncrementalDecoder(codecs.BufferedIncrementalDecoder):
def _buffer_decode(self, data, errors, final):
if errors != 'strict':
raise IDNAError("Unsupported error handling \"{0}\"".format(errors))
if not data:
return (u"", 0)
# IDNA allows decoding to operate on Unicode strings, too.
if isinstance(data, unicode):
labels = _unicode_dots_re.split(data)
else:
# Must be ASCII string
data = str(data)
unicode(data, "ascii")
labels = data.split(".")
trailing_dot = u''
if labels:
if not labels[-1]:
trailing_dot = u'.'
del labels[-1]
elif not final:
# Keep potentially unfinished label until the next call
del labels[-1]
if labels:
trailing_dot = u'.'
result = []
size = 0
for label in labels:
result.append(ulabel(label))
if size:
size += 1
size += len(label)
result = u".".join(result) + trailing_dot
size += len(trailing_dot)
return (result, size)
class StreamWriter(Codec, codecs.StreamWriter):
pass
class StreamReader(Codec, codecs.StreamReader):
pass
def getregentry():
return codecs.CodecInfo(
name='idna',
encode=Codec().encode,
decode=Codec().decode,
incrementalencoder=IncrementalEncoder,
incrementaldecoder=IncrementalDecoder,
streamwriter=StreamWriter,
streamreader=StreamReader,
)

12
lib/idna/compat.py Normal file
View File

@@ -0,0 +1,12 @@
from .core import *
from .codec import *
def ToASCII(label):
return encode(label)
def ToUnicode(label):
return decode(label)
def nameprep(s):
raise NotImplementedError("IDNA 2008 does not utilise nameprep protocol")

387
lib/idna/core.py Normal file
View File

@@ -0,0 +1,387 @@
from . import idnadata
import bisect
import unicodedata
import re
import sys
from .intranges import intranges_contain
_virama_combining_class = 9
_alabel_prefix = b'xn--'
_unicode_dots_re = re.compile(u'[\u002e\u3002\uff0e\uff61]')
if sys.version_info[0] == 3:
unicode = str
unichr = chr
class IDNAError(UnicodeError):
""" Base exception for all IDNA-encoding related problems """
pass
class IDNABidiError(IDNAError):
""" Exception when bidirectional requirements are not satisfied """
pass
class InvalidCodepoint(IDNAError):
""" Exception when a disallowed or unallocated codepoint is used """
pass
class InvalidCodepointContext(IDNAError):
""" Exception when the codepoint is not valid in the context it is used """
pass
def _combining_class(cp):
return unicodedata.combining(unichr(cp))
def _is_script(cp, script):
return intranges_contain(ord(cp), idnadata.scripts[script])
def _punycode(s):
return s.encode('punycode')
def _unot(s):
return 'U+{0:04X}'.format(s)
def valid_label_length(label):
if len(label) > 63:
return False
return True
def valid_string_length(label, trailing_dot):
if len(label) > (254 if trailing_dot else 253):
return False
return True
def check_bidi(label, check_ltr=False):
# Bidi rules should only be applied if string contains RTL characters
bidi_label = False
for (idx, cp) in enumerate(label, 1):
direction = unicodedata.bidirectional(cp)
if direction == '':
# String likely comes from a newer version of Unicode
raise IDNABidiError('Unknown directionality in label {0} at position {1}'.format(repr(label), idx))
if direction in ['R', 'AL', 'AN']:
bidi_label = True
break
if not bidi_label and not check_ltr:
return True
# Bidi rule 1
direction = unicodedata.bidirectional(label[0])
if direction in ['R', 'AL']:
rtl = True
elif direction == 'L':
rtl = False
else:
raise IDNABidiError('First codepoint in label {0} must be directionality L, R or AL'.format(repr(label)))
valid_ending = False
number_type = False
for (idx, cp) in enumerate(label, 1):
direction = unicodedata.bidirectional(cp)
if rtl:
# Bidi rule 2
if not direction in ['R', 'AL', 'AN', 'EN', 'ES', 'CS', 'ET', 'ON', 'BN', 'NSM']:
raise IDNABidiError('Invalid direction for codepoint at position {0} in a right-to-left label'.format(idx))
# Bidi rule 3
if direction in ['R', 'AL', 'EN', 'AN']:
valid_ending = True
elif direction != 'NSM':
valid_ending = False
# Bidi rule 4
if direction in ['AN', 'EN']:
if not number_type:
number_type = direction
else:
if number_type != direction:
raise IDNABidiError('Can not mix numeral types in a right-to-left label')
else:
# Bidi rule 5
if not direction in ['L', 'EN', 'ES', 'CS', 'ET', 'ON', 'BN', 'NSM']:
raise IDNABidiError('Invalid direction for codepoint at position {0} in a left-to-right label'.format(idx))
# Bidi rule 6
if direction in ['L', 'EN']:
valid_ending = True
elif direction != 'NSM':
valid_ending = False
if not valid_ending:
raise IDNABidiError('Label ends with illegal codepoint directionality')
return True
def check_initial_combiner(label):
if unicodedata.category(label[0])[0] == 'M':
raise IDNAError('Label begins with an illegal combining character')
return True
def check_hyphen_ok(label):
if label[2:4] == '--':
raise IDNAError('Label has disallowed hyphens in 3rd and 4th position')
if label[0] == '-' or label[-1] == '-':
raise IDNAError('Label must not start or end with a hyphen')
return True
def check_nfc(label):
if unicodedata.normalize('NFC', label) != label:
raise IDNAError('Label must be in Normalization Form C')
def valid_contextj(label, pos):
cp_value = ord(label[pos])
if cp_value == 0x200c:
if pos > 0:
if _combining_class(ord(label[pos - 1])) == _virama_combining_class:
return True
ok = False
for i in range(pos-1, -1, -1):
joining_type = idnadata.joining_types.get(ord(label[i]))
if joining_type == ord('T'):
continue
if joining_type in [ord('L'), ord('D')]:
ok = True
break
if not ok:
return False
ok = False
for i in range(pos+1, len(label)):
joining_type = idnadata.joining_types.get(ord(label[i]))
if joining_type == ord('T'):
continue
if joining_type in [ord('R'), ord('D')]:
ok = True
break
return ok
if cp_value == 0x200d:
if pos > 0:
if _combining_class(ord(label[pos - 1])) == _virama_combining_class:
return True
return False
else:
return False
def valid_contexto(label, pos, exception=False):
cp_value = ord(label[pos])
if cp_value == 0x00b7:
if 0 < pos < len(label)-1:
if ord(label[pos - 1]) == 0x006c and ord(label[pos + 1]) == 0x006c:
return True
return False
elif cp_value == 0x0375:
if pos < len(label)-1 and len(label) > 1:
return _is_script(label[pos + 1], 'Greek')
return False
elif cp_value == 0x05f3 or cp_value == 0x05f4:
if pos > 0:
return _is_script(label[pos - 1], 'Hebrew')
return False
elif cp_value == 0x30fb:
for cp in label:
if cp == u'\u30fb':
continue
if _is_script(cp, 'Hiragana') or _is_script(cp, 'Katakana') or _is_script(cp, 'Han'):
return True
return False
elif 0x660 <= cp_value <= 0x669:
for cp in label:
if 0x6f0 <= ord(cp) <= 0x06f9:
return False
return True
elif 0x6f0 <= cp_value <= 0x6f9:
for cp in label:
if 0x660 <= ord(cp) <= 0x0669:
return False
return True
def check_label(label):
if isinstance(label, (bytes, bytearray)):
label = label.decode('utf-8')
if len(label) == 0:
raise IDNAError('Empty Label')
check_nfc(label)
check_hyphen_ok(label)
check_initial_combiner(label)
for (pos, cp) in enumerate(label):
cp_value = ord(cp)
if intranges_contain(cp_value, idnadata.codepoint_classes['PVALID']):
continue
elif intranges_contain(cp_value, idnadata.codepoint_classes['CONTEXTJ']):
if not valid_contextj(label, pos):
raise InvalidCodepointContext('Joiner {0} not allowed at position {1} in {2}'.format(_unot(cp_value), pos+1, repr(label)))
elif intranges_contain(cp_value, idnadata.codepoint_classes['CONTEXTO']):
if not valid_contexto(label, pos):
raise InvalidCodepointContext('Codepoint {0} not allowed at position {1} in {2}'.format(_unot(cp_value), pos+1, repr(label)))
else:
raise InvalidCodepoint('Codepoint {0} at position {1} of {2} not allowed'.format(_unot(cp_value), pos+1, repr(label)))
check_bidi(label)
def alabel(label):
try:
label = label.encode('ascii')
try:
ulabel(label)
except IDNAError:
raise IDNAError('The label {0} is not a valid A-label'.format(label))
if not valid_label_length(label):
raise IDNAError('Label too long')
return label
except UnicodeEncodeError:
pass
if not label:
raise IDNAError('No Input')
label = unicode(label)
check_label(label)
label = _punycode(label)
label = _alabel_prefix + label
if not valid_label_length(label):
raise IDNAError('Label too long')
return label
def ulabel(label):
if not isinstance(label, (bytes, bytearray)):
try:
label = label.encode('ascii')
except UnicodeEncodeError:
check_label(label)
return label
label = label.lower()
if label.startswith(_alabel_prefix):
label = label[len(_alabel_prefix):]
else:
check_label(label)
return label.decode('ascii')
label = label.decode('punycode')
check_label(label)
return label
def uts46_remap(domain, std3_rules=True, transitional=False):
"""Re-map the characters in the string according to UTS46 processing."""
from .uts46data import uts46data
output = u""
try:
for pos, char in enumerate(domain):
code_point = ord(char)
uts46row = uts46data[code_point if code_point < 256 else
bisect.bisect_left(uts46data, (code_point, "Z")) - 1]
status = uts46row[1]
replacement = uts46row[2] if len(uts46row) == 3 else None
if (status == "V" or
(status == "D" and not transitional) or
(status == "3" and std3_rules and replacement is None)):
output += char
elif replacement is not None and (status == "M" or
(status == "3" and std3_rules) or
(status == "D" and transitional)):
output += replacement
elif status != "I":
raise IndexError()
return unicodedata.normalize("NFC", output)
except IndexError:
raise InvalidCodepoint(
"Codepoint {0} not allowed at position {1} in {2}".format(
_unot(code_point), pos + 1, repr(domain)))
def encode(s, strict=False, uts46=False, std3_rules=False, transitional=False):
if isinstance(s, (bytes, bytearray)):
s = s.decode("ascii")
if uts46:
s = uts46_remap(s, std3_rules, transitional)
trailing_dot = False
result = []
if strict:
labels = s.split('.')
else:
labels = _unicode_dots_re.split(s)
while labels and not labels[0]:
del labels[0]
if not labels:
raise IDNAError('Empty domain')
if labels[-1] == '':
del labels[-1]
trailing_dot = True
for label in labels:
result.append(alabel(label))
if trailing_dot:
result.append(b'')
s = b'.'.join(result)
if not valid_string_length(s, trailing_dot):
raise IDNAError('Domain too long')
return s
def decode(s, strict=False, uts46=False, std3_rules=False):
if isinstance(s, (bytes, bytearray)):
s = s.decode("ascii")
if uts46:
s = uts46_remap(s, std3_rules, False)
trailing_dot = False
result = []
if not strict:
labels = _unicode_dots_re.split(s)
else:
labels = s.split(u'.')
while labels and not labels[0]:
del labels[0]
if not labels:
raise IDNAError('Empty domain')
if not labels[-1]:
del labels[-1]
trailing_dot = True
for label in labels:
result.append(ulabel(label))
if trailing_dot:
result.append(u'')
return u'.'.join(result)

1585
lib/idna/idnadata.py Normal file

File diff suppressed because it is too large Load Diff

53
lib/idna/intranges.py Normal file
View File

@@ -0,0 +1,53 @@
"""
Given a list of integers, made up of (hopefully) a small number of long runs
of consecutive integers, compute a representation of the form
((start1, end1), (start2, end2) ...). Then answer the question "was x present
in the original list?" in time O(log(# runs)).
"""
import bisect
def intranges_from_list(list_):
"""Represent a list of integers as a sequence of ranges:
((start_0, end_0), (start_1, end_1), ...), such that the original
integers are exactly those x such that start_i <= x < end_i for some i.
Ranges are encoded as single integers (start << 32 | end), not as tuples.
"""
sorted_list = sorted(list_)
ranges = []
last_write = -1
for i in range(len(sorted_list)):
if i+1 < len(sorted_list):
if sorted_list[i] == sorted_list[i+1]-1:
continue
current_range = sorted_list[last_write+1:i+1]
ranges.append(_encode_range(current_range[0], current_range[-1] + 1))
last_write = i
return tuple(ranges)
def _encode_range(start, end):
return (start << 32) | end
def _decode_range(r):
return (r >> 32), (r & ((1 << 32) - 1))
def intranges_contain(int_, ranges):
"""Determine if `int_` falls into one of the ranges in `ranges`."""
tuple_ = _encode_range(int_, 0)
pos = bisect.bisect_left(ranges, tuple_)
# we could be immediately ahead of a tuple (start, end)
# with start < int_ <= end
if pos > 0:
left, right = _decode_range(ranges[pos-1])
if left <= int_ < right:
return True
# or we could be immediately behind a tuple (int_, end)
if pos < len(ranges):
left, _ = _decode_range(ranges[pos])
if left == int_:
return True
return False

2
lib/idna/package_data.py Normal file
View File

@@ -0,0 +1,2 @@
__version__ = '2.6'

7634
lib/idna/uts46data.py Normal file

File diff suppressed because it is too large Load Diff

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()
@@ -443,6 +447,7 @@ def dbcheck():
'transcode_protocol TEXT, transcode_container TEXT, '
'transcode_video_codec TEXT, transcode_audio_codec TEXT, transcode_audio_channels INTEGER,'
'transcode_width INTEGER, transcode_height INTEGER, '
'transcode_hw_decoding INTEGER, transcode_hw_encoding INTEGER, '
'optimized_version INTEGER, optimized_version_profile TEXT, optimized_version_title TEXT, '
'synced_version INTEGER, synced_version_profile TEXT, '
'buffer_count INTEGER DEFAULT 0, buffer_last_triggered INTEGER, last_paused INTEGER, write_attempts INTEGER DEFAULT 0, '
@@ -468,8 +473,9 @@ def dbcheck():
'audio_bitrate INTEGER, audio_codec TEXT, audio_channels INTEGER, transcode_protocol TEXT, '
'transcode_container TEXT, transcode_video_codec TEXT, transcode_audio_codec TEXT, '
'transcode_audio_channels INTEGER, transcode_width INTEGER, transcode_height INTEGER, '
'transcode_hw_requested INTEGER, transcode_hw_full_pipeline INTEGER, transcode_hw_decode TEXT, '
'transcode_hw_decode_title TEXT, transcode_hw_encode TEXT, transcode_hw_encode_title TEXT, '
'transcode_hw_requested INTEGER, transcode_hw_full_pipeline INTEGER, '
'transcode_hw_decode TEXT, transcode_hw_decode_title TEXT, transcode_hw_decoding INTEGER, '
'transcode_hw_encode TEXT, transcode_hw_encode_title TEXT, transcode_hw_encoding INTEGER, '
'stream_container TEXT, stream_container_decision TEXT, stream_bitrate INTEGER, '
'stream_video_decision TEXT, stream_video_bitrate INTEGER, stream_video_codec TEXT, stream_video_codec_level TEXT, '
'stream_video_bit_depth INTEGER, stream_video_height INTEGER, stream_video_width INTEGER, stream_video_resolution TEXT, '
@@ -496,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, '
@@ -917,6 +923,18 @@ def dbcheck():
'ALTER TABLE sessions ADD COLUMN optimized_version_title TEXT'
)
# Upgrade sessions table from earlier versions
try:
c_db.execute('SELECT transcode_hw_decoding FROM sessions')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table sessions.")
c_db.execute(
'ALTER TABLE sessions ADD COLUMN transcode_hw_decoding INTEGER'
)
c_db.execute(
'ALTER TABLE sessions ADD COLUMN transcode_hw_encoding INTEGER'
)
# Upgrade session_history table from earlier versions
try:
c_db.execute('SELECT reference_id FROM session_history')
@@ -1159,6 +1177,43 @@ def dbcheck():
'ALTER TABLE session_history_media_info ADD COLUMN optimized_version_title TEXT '
)
# Upgrade session_history_media_info table from earlier versions
try:
c_db.execute('SELECT transcode_hw_decoding FROM session_history_media_info')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table session_history_media_info.")
c_db.execute(
'ALTER TABLE session_history_media_info ADD COLUMN transcode_hw_decoding INTEGER '
)
c_db.execute(
'ALTER TABLE session_history_media_info ADD COLUMN transcode_hw_encoding INTEGER '
)
c_db.execute(
'UPDATE session_history_media_info SET subtitle_codec = "" WHERE subtitle_codec IS NULL '
)
# 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')
@@ -1234,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')
@@ -1340,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

@@ -14,7 +14,7 @@
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
import datetime
import threading
import os
import time
from apscheduler.schedulers.background import BackgroundScheduler
@@ -26,7 +26,6 @@ import datafactory
import helpers
import logger
import notification_handler
import notifiers
import pmsconnect
@@ -34,6 +33,7 @@ ACTIVITY_SCHED = BackgroundScheduler()
RECENTLY_ADDED_QUEUE = {}
class ActivityHandler(object):
def __init__(self, timeline):
@@ -75,9 +75,12 @@ class ActivityHandler(object):
monitor_proc.write_session(session=session, notify=False)
def on_start(self):
if self.is_valid_session() and self.get_live_session():
if self.is_valid_session():
session = self.get_live_session()
if not session:
return
# Some DLNA clients create a new session temporarily when browsing the library
# Wait and get session again to make sure it is an actual session
if session['platform'] == 'DLNA':
@@ -124,6 +127,7 @@ class ActivityHandler(object):
logger.debug(u"Tautulli ActivityHandler :: Removing sessionKey %s ratingKey %s from session queue"
% (str(self.get_session_key()), str(self.get_rating_key())))
ap.delete_session(session_key=self.get_session_key())
delete_metadata_cache(self.get_session_key())
def on_pause(self, still_paused=False):
if self.is_valid_session():
@@ -226,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:
@@ -436,12 +442,12 @@ def force_stop_stream(session_key):
ap.delete_session(session_key=session_key)
else:
sessions['write_attempts'] += 1
session['write_attempts'] += 1
if sessions['write_attempts'] < plexpy.CONFIG.SESSION_DB_WRITE_ATTEMPTS:
if session['write_attempts'] < plexpy.CONFIG.SESSION_DB_WRITE_ATTEMPTS:
logger.warn(u"Tautulli ActivityHandler :: Failed to write stream with sessionKey %s ratingKey %s to the database. " \
"Will try again in 30 seconds. Write attempt %s."
% (sessions['session_key'], sessions['rating_key'], str(sessions['write_attempts'])))
% (session['session_key'], session['rating_key'], str(session['write_attempts'])))
ap.increment_write_attempts(session_key=session_key)
# Reschedule for 30 seconds later
@@ -449,12 +455,13 @@ def force_stop_stream(session_key):
args=[session_key], seconds=30)
else:
logger.warn(u"Tautulli Monitor :: Failed to write stream with sessionKey %s ratingKey %s to the database. " \
logger.warn(u"Tautulli ActivityHandler :: Failed to write stream with sessionKey %s ratingKey %s to the database. " \
"Removing session from the database. Write attempt %s."
% (sessions['session_key'], sessions['rating_key'], str(sessions['write_attempts'])))
logger.info(u"Tautulli Monitor :: Removing stale stream with sessionKey %s ratingKey %s from session queue"
% (sessions['session_key'], sessions['rating_key']))
% (session['session_key'], session['rating_key'], str(session['write_attempts'])))
logger.info(u"Tautulli ActivityHandler :: Removing stale stream with sessionKey %s ratingKey %s from session queue"
% (session['session_key'], session['rating_key']))
ap.delete_session(session_key=session_key)
delete_metadata_cache(session_key)
def clear_recently_added_queue(rating_key):
@@ -493,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:
@@ -519,3 +528,11 @@ def on_created(rating_key, **kwargs):
else:
logger.error(u"Tautulli TimelineHandler :: Unable to retrieve metadata for rating_key %s" % str(rating_key))
def delete_metadata_cache(session_key):
try:
os.remove(os.path.join(plexpy.CONFIG.CACHE_DIR, 'metadata-sessionKey-%s.json' % session_key))
except IOError as e:
logger.error(u"Tautulli ActivityHandler :: Failed to remove metadata cache file (sessionKey %s): %s"
% (session_key, e))

View File

@@ -58,7 +58,7 @@ class ActivityProcessor(object):
'grandparent_thumb': session.get('grandparent_thumb', ''),
'year': session.get('year', ''),
'friendly_name': session.get('friendly_name', ''),
#'ip_address': session.get('ip_address', ''),
'ip_address': session.get('ip_address', ''),
'player': session.get('player', ''),
'platform': session.get('platform', ''),
'parent_rating_key': session.get('parent_rating_key', ''),
@@ -90,6 +90,8 @@ class ActivityProcessor(object):
'transcode_audio_channels': session.get('transcode_audio_channels', ''),
'transcode_width': session.get('stream_video_width', ''),
'transcode_height': session.get('stream_video_height', ''),
'transcode_hw_decoding': session.get('transcode_hw_decoding', ''),
'transcode_hw_encoding': session.get('transcode_hw_encoding', ''),
'synced_version': session.get('synced_version', ''),
'synced_version_profile': session.get('synced_version_profile', ''),
'optimized_version': session.get('optimized_version', ''),
@@ -117,10 +119,6 @@ class ActivityProcessor(object):
'stopped': int(time.time())
}
# Add ip_address back into values
if session['ip_address']:
values.update({'ip_address': session.get('ip_address', 'N/A')})
keys = {'session_key': session.get('session_key', ''),
'rating_key': session.get('rating_key', '')}
@@ -129,7 +127,6 @@ class ActivityProcessor(object):
if result == 'insert':
# Check if any notification agents have notifications enabled
if notify:
values.update({'ip_address': session.get('ip_address', 'N/A')})
plexpy.NOTIFY_QUEUE.put({'stream_data': values, 'notify_action': 'on_play'})
# If it's our first write then time stamp it.
@@ -324,6 +321,7 @@ class ActivityProcessor(object):
'audio_codec': session['audio_codec'],
'audio_bitrate': session['audio_bitrate'],
'audio_channels': session['audio_channels'],
'subtitle_codec': session['subtitle_codec'],
'transcode_protocol': session['transcode_protocol'],
'transcode_container': session['transcode_container'],
'transcode_video_codec': session['transcode_video_codec'],
@@ -333,9 +331,11 @@ class ActivityProcessor(object):
'transcode_height': session['transcode_height'],
'transcode_hw_requested': session['transcode_hw_requested'],
'transcode_hw_full_pipeline': session['transcode_hw_full_pipeline'],
'transcode_hw_decoding': session['transcode_hw_decoding'],
'transcode_hw_decode': session['transcode_hw_decode'],
'transcode_hw_encode': session['transcode_hw_encode'],
'transcode_hw_decode_title': session['transcode_hw_decode_title'],
'transcode_hw_encoding': session['transcode_hw_encoding'],
'transcode_hw_encode': session['transcode_hw_encode'],
'transcode_hw_encode_title': session['transcode_hw_encode_title'],
'stream_container': session['stream_container'],
'stream_container_decision': session['stream_container_decision'],

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,130 +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))
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'
]
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'
]
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'},
]
},
{
@@ -163,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'},
]
},
{
@@ -178,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'},
]
},
{
@@ -186,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'},
]
},
{
@@ -214,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'},
]
},
{
@@ -224,7 +273,7 @@ DATE_TIME_FORMATS = [
{'value': 'X', 'description': 'Unix timestamp', 'example': 'E.g. 1456887825'},
]
},
]
]
NOTIFICATION_PARAMETERS = [
{
@@ -306,7 +355,13 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Transcode Video Height', 'type': 'int', 'value': 'transcode_video_height', 'description': 'The video height of the transcoded stream.'},
{'name': 'Transcode Audio Codec', 'type': 'str', 'value': 'transcode_audio_codec', 'description': 'The audio codec of the transcoded stream.'},
{'name': 'Transcode Audio Channels', 'type': 'float', 'value': 'transcode_audio_channels', 'description': 'The audio channels of the transcoded stream.'},
{'name': 'Transcode Hardware', 'type': 'int', 'value': 'transcode_hardware', 'description': 'If hardware transcoding is used.', 'example': '0 or 1'},
{'name': 'Transcode HW Requested', 'type': 'int', 'value': 'transcode_hw_requested', 'description': 'If hardware decoding/encoding was requested.', 'example': '0 or 1'},
{'name': 'Transcode HW Decoding', 'type': 'int', 'value': 'transcode_hw_decoding', 'description': 'If hardware decoding is used.', 'example': '0 or 1'},
{'name': 'Transcode HW Decoding Codec', 'type': 'str', 'value': 'transcode_hw_decode', 'description': 'The hardware decoding codec.'},
{'name': 'Transcode HW Decoding Title', 'type': 'str', 'value': 'transcode_hw_decode_title', 'description': 'The hardware decoding codec title.'},
{'name': 'Transcode HW Encoding', 'type': 'int', 'value': 'transcode_hw_encoding', 'description': 'If hardware encoding is used.', 'example': '0 or 1'},
{'name': 'Transcode HW Encoding Codec', 'type': 'str', 'value': 'transcode_hw_encode', 'description': 'The hardware encoding codec.'},
{'name': 'Transcode HW Encoding Title', 'type': 'str', 'value': 'transcode_hw_encode_title', 'description': 'The hardware encoding codec title.'},
{'name': 'Session Key', 'type': 'str', 'value': 'session_key', 'description': 'The unique identifier for the session.'},
{'name': 'Transcode Key', 'type': 'str', 'value': 'transcode_key', 'description': 'The unique identifier for the transcode session.'},
{'name': 'Session ID', 'type': 'str', 'value': 'session_id', 'description': 'The unique identifier for the stream.'},
@@ -333,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.'},
@@ -345,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.'},
@@ -430,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

@@ -882,6 +882,7 @@ class DataFactory(object):
'stream_video_framerate, ' \
'stream_audio_decision, stream_audio_codec, stream_audio_bitrate, stream_audio_channels, ' \
'subtitles, stream_subtitle_decision, stream_subtitle_codec, ' \
'transcode_hw_decoding, transcode_hw_encoding, ' \
'session_history_metadata.media_type, title, grandparent_title ' \
'FROM session_history_media_info ' \
'JOIN session_history ON session_history_media_info.id = session_history.id ' \
@@ -899,6 +900,7 @@ class DataFactory(object):
'stream_video_framerate, ' \
'stream_audio_decision, stream_audio_codec, stream_audio_bitrate, stream_audio_channels, ' \
'subtitles, stream_subtitle_decision, stream_subtitle_codec, ' \
'transcode_hw_decoding, transcode_hw_encoding, ' \
'media_type, title, grandparent_title ' \
'FROM sessions ' \
'WHERE session_key = ? %s' % user_cond
@@ -945,11 +947,15 @@ class DataFactory(object):
'subtitles': item['subtitles'],
'stream_subtitle_decision': item['stream_subtitle_decision'],
'stream_subtitle_codec': item['stream_subtitle_codec'],
'transcode_hw_decoding': item['transcode_hw_decoding'],
'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

@@ -154,7 +154,7 @@ class HTTPHandler(object):
try:
if self.output_format == 'text':
output = response_content.decode('utf-8', 'ignore')
if self.output_format == 'dict':
elif self.output_format == 'dict':
output = helpers.convert_xml_to_dict(response_content)
elif self.output_format == 'json':
output = helpers.convert_xml_to_json(response_content)

View File

@@ -16,7 +16,7 @@
import arrow
import bleach
from collections import Counter
from collections import Counter, defaultdict
from itertools import groupby
import json
from operator import itemgetter
@@ -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 {}
@@ -449,17 +455,20 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
elif timeline:
rating_key = timeline['rating_key']
pms_connect = pmsconnect.PmsConnect()
metadata = pms_connect.get_metadata_details(rating_key=rating_key)
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 not metadata:
logger.error(u"Tautulli NotificationHandler :: Unable to retrieve metadata for rating_key %s" % str(rating_key))
return None
if timeline:
notify_params.update(timeline)
## TODO: Check list of media info items, currently only grabs first item
media_info = media_part_info = {}
if 'media_info' in metadata and len(metadata['media_info']) > 0:
media_info = metadata['media_info'][0]
if 'media_info' in notify_params and len(notify_params['media_info']) > 0:
media_info = notify_params['media_info'][0]
if 'parts' in media_info and len(media_info['parts']) > 0:
media_part_info = media_info.pop('parts')[0]
@@ -476,11 +485,14 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
media_part_info.update(stream)
stream_subtitle = True
notify_params.update(media_info)
notify_params.update(media_part_info)
child_metadata = grandchild_metadata = []
for key in kwargs.pop('child_keys', []):
child_metadata.append(pms_connect.get_metadata_details(rating_key=key))
child_metadata.append(pmsconnect.PmsConnect().get_metadata_details(rating_key=key))
for key in kwargs.pop('grandchild_keys', []):
grandchild_metadata.append(pms_connect.get_metadata_details(rating_key=key))
grandchild_metadata.append(pmsconnect.PmsConnect().get_metadata_details(rating_key=key))
# Session values
session = session or {}
@@ -507,102 +519,102 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
stream_duration = 0
view_offset = helpers.convert_milliseconds_to_minutes(session.get('view_offset', 0))
duration = helpers.convert_milliseconds_to_minutes(metadata['duration'])
duration = helpers.convert_milliseconds_to_minutes(notify_params['duration'])
remaining_duration = duration - view_offset
# Build Plex URL
metadata['plex_url'] = '{web_url}#!/server/{pms_identifier}/details?key=%2Flibrary%2Fmetadata%2F{rating_key}'.format(
notify_params['plex_url'] = '{web_url}#!/server/{pms_identifier}/details?key=%2Flibrary%2Fmetadata%2F{rating_key}'.format(
web_url=plexpy.CONFIG.PMS_WEB_URL,
pms_identifier=plexpy.CONFIG.PMS_IDENTIFIER,
rating_key=rating_key)
# Get media IDs from guid and build URLs
if 'imdb://' in metadata['guid']:
metadata['imdb_id'] = metadata['guid'].split('imdb://')[1].split('?')[0]
metadata['imdb_url'] = 'https://www.imdb.com/title/' + metadata['imdb_id']
metadata['trakt_url'] = 'https://trakt.tv/search/imdb/' + metadata['imdb_id']
if 'imdb://' in notify_params['guid']:
notify_params['imdb_id'] = notify_params['guid'].split('imdb://')[1].split('?')[0]
notify_params['imdb_url'] = 'https://www.imdb.com/title/' + notify_params['imdb_id']
notify_params['trakt_url'] = 'https://trakt.tv/search/imdb/' + notify_params['imdb_id']
if 'thetvdb://' in metadata['guid']:
metadata['thetvdb_id'] = metadata['guid'].split('thetvdb://')[1].split('/')[0]
metadata['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + metadata['thetvdb_id']
metadata['trakt_url'] = 'https://trakt.tv/search/tvdb/' + metadata['thetvdb_id'] + '?id_type=show'
if 'thetvdb://' in notify_params['guid']:
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'
elif 'thetvdbdvdorder://' in metadata['guid']:
metadata['thetvdb_id'] = metadata['guid'].split('thetvdbdvdorder://')[1].split('/')[0]
metadata['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + metadata['thetvdb_id']
metadata['trakt_url'] = 'https://trakt.tv/search/tvdb/' + metadata['thetvdb_id'] + '?id_type=show'
elif 'thetvdbdvdorder://' in notify_params['guid']:
notify_params['thetvdb_id'] = notify_params['guid'].split('thetvdbdvdorder://')[1].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'
if 'themoviedb://' in metadata['guid']:
if metadata['media_type'] == 'movie':
metadata['themoviedb_id'] = metadata['guid'].split('themoviedb://')[1].split('?')[0]
metadata['themoviedb_url'] = 'https://www.themoviedb.org/movie/' + metadata['themoviedb_id']
metadata['trakt_url'] = 'https://trakt.tv/search/tmdb/' + metadata['themoviedb_id'] + '?id_type=movie'
if 'themoviedb://' in notify_params['guid']:
if notify_params['media_type'] == 'movie':
notify_params['themoviedb_id'] = notify_params['guid'].split('themoviedb://')[1].split('?')[0]
notify_params['themoviedb_url'] = 'https://www.themoviedb.org/movie/' + notify_params['themoviedb_id']
notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/' + notify_params['themoviedb_id'] + '?id_type=movie'
elif metadata['media_type'] in ('show', 'season', 'episode'):
metadata['themoviedb_id'] = metadata['guid'].split('themoviedb://')[1].split('/')[0]
metadata['themoviedb_url'] = 'https://www.themoviedb.org/tv/' + metadata['themoviedb_id']
metadata['trakt_url'] = 'https://trakt.tv/search/tmdb/' + metadata['themoviedb_id'] + '?id_type=show'
elif notify_params['media_type'] in ('show', 'season', 'episode'):
notify_params['themoviedb_id'] = notify_params['guid'].split('themoviedb://')[1].split('/')[0]
notify_params['themoviedb_url'] = 'https://www.themoviedb.org/tv/' + notify_params['themoviedb_id']
notify_params['trakt_url'] = 'https://trakt.tv/search/tmdb/' + notify_params['themoviedb_id'] + '?id_type=show'
if 'lastfm://' in metadata['guid']:
metadata['lastfm_id'] = metadata['guid'].split('lastfm://')[1].rsplit('/', 1)[0]
metadata['lastfm_url'] = 'https://www.last.fm/music/' + metadata['lastfm_id']
if 'lastfm://' in notify_params['guid']:
notify_params['lastfm_id'] = notify_params['guid'].split('lastfm://')[1].rsplit('/', 1)[0]
notify_params['lastfm_url'] = 'https://www.last.fm/music/' + notify_params['lastfm_id']
# Get TheMovieDB info
if plexpy.CONFIG.THEMOVIEDB_LOOKUP:
if metadata.get('themoviedb_id'):
if notify_params.get('themoviedb_id'):
themoveidb_json = get_themoviedb_info(rating_key=rating_key,
media_type=metadata['media_type'],
themoviedb_id=metadata['themoviedb_id'])
media_type=notify_params['media_type'],
themoviedb_id=notify_params['themoviedb_id'])
if themoveidb_json.get('imdb_id'):
metadata['imdb_id'] = themoveidb_json['imdb_id']
metadata['imdb_url'] = 'https://www.imdb.com/title/' + themoveidb_json['imdb_id']
notify_params['imdb_id'] = themoveidb_json['imdb_id']
notify_params['imdb_url'] = 'https://www.imdb.com/title/' + themoveidb_json['imdb_id']
elif metadata.get('thetvdb_id') or metadata.get('imdb_id'):
elif notify_params.get('thetvdb_id') or notify_params.get('imdb_id'):
themoviedb_info = lookup_themoviedb_by_id(rating_key=rating_key,
thetvdb_id=metadata.get('thetvdb_id'),
imdb_id=metadata.get('imdb_id'))
metadata.update(themoviedb_info)
thetvdb_id=notify_params.get('thetvdb_id'),
imdb_id=notify_params.get('imdb_id'))
notify_params.update(themoviedb_info)
# Get TVmaze info (for tv shows only)
if plexpy.CONFIG.TVMAZE_LOOKUP:
if metadata['media_type'] in ('show', 'season', 'episode') and (metadata.get('thetvdb_id') or metadata.get('imdb_id')):
if notify_params['media_type'] in ('show', 'season', 'episode') and (notify_params.get('thetvdb_id') or notify_params.get('imdb_id')):
tvmaze_info = lookup_tvmaze_by_id(rating_key=rating_key,
thetvdb_id=metadata.get('thetvdb_id'),
imdb_id=metadata.get('imdb_id'))
metadata.update(tvmaze_info)
thetvdb_id=notify_params.get('thetvdb_id'),
imdb_id=notify_params.get('imdb_id'))
notify_params.update(tvmaze_info)
if tvmaze_info.get('thetvdb_id'):
metadata['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + str(tvmaze_info['thetvdb_id'])
notify_params['thetvdb_url'] = 'https://thetvdb.com/?tab=series&id=' + str(tvmaze_info['thetvdb_id'])
if tvmaze_info.get('imdb_id'):
metadata['imdb_url'] = 'https://www.imdb.com/title/' + tvmaze_info['imdb_id']
notify_params['imdb_url'] = 'https://www.imdb.com/title/' + tvmaze_info['imdb_id']
if metadata['media_type'] in ('movie', 'show', 'artist'):
poster_thumb = metadata['thumb']
poster_key = metadata['rating_key']
poster_title = metadata['title']
elif metadata['media_type'] in ('season', 'album'):
poster_thumb = metadata['thumb'] or metadata['parent_thumb']
poster_key = metadata['rating_key']
poster_title = '%s - %s' % (metadata['parent_title'],
metadata['title'])
elif metadata['media_type'] in ('episode', 'track'):
poster_thumb = metadata['parent_thumb'] or metadata['grandparent_thumb']
poster_key = metadata['parent_rating_key']
poster_title = '%s - %s' % (metadata['grandparent_title'],
metadata['parent_title'])
if notify_params['media_type'] in ('movie', 'show', 'artist'):
poster_thumb = notify_params['thumb']
poster_key = notify_params['rating_key']
poster_title = notify_params['title']
elif notify_params['media_type'] in ('season', 'album'):
poster_thumb = notify_params['thumb'] or notify_params['parent_thumb']
poster_key = notify_params['rating_key']
poster_title = '%s - %s' % (notify_params['parent_title'],
notify_params['title'])
elif notify_params['media_type'] in ('episode', 'track'):
poster_thumb = notify_params['parent_thumb'] or notify_params['grandparent_thumb']
poster_key = notify_params['parent_rating_key']
poster_title = '%s - %s' % (notify_params['grandparent_title'],
notify_params['parent_title'])
else:
poster_thumb = ''
if plexpy.CONFIG.NOTIFY_UPLOAD_POSTERS:
poster_info = get_poster_info(poster_thumb=poster_thumb, poster_key=poster_key, poster_title=poster_title)
metadata.update(poster_info)
notify_params.update(poster_info)
if ((manual_trigger or plexpy.CONFIG.NOTIFY_GROUP_RECENTLY_ADDED_GRANDPARENT)
and metadata['media_type'] in ('show', 'artist')):
show_name = metadata['title']
and notify_params['media_type'] in ('show', 'artist')):
show_name = notify_params['title']
episode_name = ''
artist_name = metadata['title']
artist_name = notify_params['title']
album_name = ''
track_name = ''
@@ -614,14 +626,14 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
track_num, track_num00 = '', ''
elif ((manual_trigger or plexpy.CONFIG.NOTIFY_GROUP_RECENTLY_ADDED_PARENT)
and metadata['media_type'] in ('season', 'album')):
show_name = metadata['parent_title']
and notify_params['media_type'] in ('season', 'album')):
show_name = notify_params['parent_title']
episode_name = ''
artist_name = metadata['parent_title']
album_name = metadata['title']
artist_name = notify_params['parent_title']
album_name = notify_params['title']
track_name = ''
season_num = metadata['media_index'].zfill(1)
season_num00 = metadata['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])
@@ -629,192 +641,196 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
track_num, track_num00 = num, num00
else:
show_name = metadata['grandparent_title']
episode_name = metadata['title']
artist_name = metadata['grandparent_title']
album_name = metadata['parent_title']
track_name = metadata['title']
season_num = metadata['parent_media_index'].zfill(1)
season_num00 = metadata['parent_media_index'].zfill(2)
episode_num = metadata['media_index'].zfill(1)
episode_num00 = metadata['media_index'].zfill(2)
track_num = metadata['media_index'].zfill(1)
track_num00 = metadata['media_index'].zfill(2)
show_name = notify_params['grandparent_title']
episode_name = notify_params['title']
artist_name = notify_params['grandparent_title']
album_name = notify_params['parent_title']
track_name = notify_params['title']
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
'plexpy_version': common.VERSION_NUMBER,
'plexpy_branch': plexpy.CONFIG.GIT_BRANCH,
'plexpy_commit': plexpy.CURRENT_VERSION,
'server_name': server_name,
'server_uptime': server_uptime,
'server_version': server_times.get('version',''),
'action': notify_action.split('on_')[-1],
'datestamp': arrow.now().format(date_format),
'timestamp': arrow.now().format(time_format),
# Stream parameters
'streams': stream_count,
'user_streams': user_stream_count,
'user': session.get('friendly_name',''),
'username': session.get('user',''),
'device': session.get('device',''),
'platform': session.get('platform',''),
'product': session.get('product',''),
'player': session.get('player',''),
'ip_address': session.get('ip_address','N/A'),
'stream_duration': stream_duration,
'stream_time': arrow.get(stream_duration * 60).format(duration_format),
'remaining_duration': remaining_duration,
'remaining_time': arrow.get(remaining_duration * 60).format(duration_format),
'progress_duration': view_offset,
'progress_time': arrow.get(view_offset * 60).format(duration_format),
'progress_percent': helpers.get_percent(view_offset, duration),
'transcode_decision': transcode_decision,
'video_decision': session.get('video_decision',''),
'audio_decision': session.get('audio_decision',''),
'subtitle_decision': session.get('subtitle_decision',''),
'quality_profile': session.get('quality_profile',''),
'optimized_version': session.get('optimized_version',''),
'optimized_version_profile': session.get('optimized_version_profile',''),
'stream_local': session.get('local', ''),
'stream_location': session.get('location', ''),
'stream_bandwidth': session.get('bandwidth', ''),
'stream_container': session.get('stream_container', ''),
'stream_bitrate': session.get('stream_bitrate', ''),
'stream_aspect_ratio': session.get('stream_aspect_ratio', ''),
'stream_video_codec': session.get('stream_video_codec', ''),
'stream_video_codec_level': session.get('stream_video_codec_level', ''),
'stream_video_bitrate': session.get('stream_video_bitrate', ''),
'stream_video_bit_depth': session.get('stream_video_bit_depth', ''),
'stream_video_framerate': session.get('stream_video_framerate', ''),
'stream_video_ref_frames': session.get('stream_video_ref_frames', ''),
'stream_video_resolution': session.get('stream_video_resolution', ''),
'stream_video_height': session.get('stream_video_height', ''),
'stream_video_width': session.get('stream_video_width', ''),
'stream_video_language': session.get('stream_video_language', ''),
'stream_video_language_code': session.get('stream_video_language_code', ''),
'stream_audio_bitrate': session.get('stream_audio_bitrate', ''),
'stream_audio_bitrate_mode': session.get('stream_audio_bitrate_mode', ''),
'stream_audio_codec': session.get('stream_audio_codec', ''),
'stream_audio_channels': session.get('stream_audio_channels', ''),
'stream_audio_channel_layout': session.get('stream_audio_channel_layout', ''),
'stream_audio_sample_rate': session.get('stream_audio_sample_rate', ''),
'stream_audio_language': session.get('stream_audio_language', ''),
'stream_audio_language_code': session.get('stream_audio_language_code', ''),
'stream_subtitle_codec': session.get('stream_subtitle_codec', ''),
'stream_subtitle_container': session.get('stream_subtitle_container', ''),
'stream_subtitle_format': session.get('stream_subtitle_format', ''),
'stream_subtitle_forced': session.get('stream_subtitle_forced', ''),
'stream_subtitle_language': session.get('stream_subtitle_language', ''),
'stream_subtitle_language_code': session.get('stream_subtitle_language_code', ''),
'stream_subtitle_location': session.get('stream_subtitle_location', ''),
'transcode_container': session.get('transcode_container',''),
'transcode_video_codec': session.get('transcode_video_codec',''),
'transcode_video_width': session.get('transcode_width',''),
'transcode_video_height': session.get('transcode_height',''),
'transcode_audio_codec': session.get('transcode_audio_codec',''),
'transcode_audio_channels': session.get('transcode_audio_channels',''),
'transcode_hw_requested': session.get('transcode_hw_requested',''),
'transcode_hw_decode': session.get('transcode_hw_decode',''),
'transcode_hw_decode_title': session.get('transcode_hw_decode_title',''),
'transcode_hw_encode': session.get('transcode_hw_encode',''),
'transcode_hw_encode_title': session.get('transcode_hw_encode_title',''),
'transcode_hw_full_pipeline': session.get('transcode_hw_full_pipeline',''),
'session_key': session.get('session_key',''),
'transcode_key': session.get('transcode_key',''),
'session_id': session.get('session_id',''),
'user_id': session.get('user_id',''),
'machine_id': session.get('machine_id',''),
# Source metadata parameters
'media_type': metadata['media_type'],
'title': metadata['full_title'],
'library_name': metadata['library_name'],
'show_name': show_name,
'episode_name': episode_name,
'artist_name': artist_name,
'album_name': album_name,
'track_name': track_name,
'season_num': season_num,
'season_num00': season_num00,
'episode_num': episode_num,
'episode_num00': episode_num00,
'track_num': track_num,
'track_num00': track_num00,
'year': metadata['year'],
'release_date': arrow.get(metadata['originally_available_at']).format(date_format)
if metadata['originally_available_at'] else '',
'air_date': arrow.get(metadata['originally_available_at']).format(date_format)
if metadata['originally_available_at'] else '',
'added_date': arrow.get(metadata['added_at']).format(date_format)
if metadata['added_at'] else '',
'updated_date': arrow.get(metadata['updated_at']).format(date_format)
if metadata['updated_at'] else '',
'last_viewed_date': arrow.get(metadata['last_viewed_at']).format(date_format)
if metadata['last_viewed_at'] else '',
'studio': metadata['studio'],
'content_rating': metadata['content_rating'],
'directors': ', '.join(metadata['directors']),
'writers': ', '.join(metadata['writers']),
'actors': ', '.join(metadata['actors']),
'genres': ', '.join(metadata['genres']),
'summary': metadata['summary'],
'tagline': metadata['tagline'],
'rating': metadata['rating'],
'audience_rating': helpers.get_percent(metadata['audience_rating'], 10) or '',
'duration': duration,
'poster_title': metadata.get('poster_title',''),
'poster_url': metadata.get('poster_url',''),
'plex_url': metadata.get('plex_url',''),
'imdb_id': metadata.get('imdb_id',''),
'imdb_url': metadata.get('imdb_url',''),
'thetvdb_id': metadata.get('thetvdb_id',''),
'thetvdb_url': metadata.get('thetvdb_url',''),
'themoviedb_id': metadata.get('themoviedb_id',''),
'themoviedb_url': metadata.get('themoviedb_url',''),
'tvmaze_id': metadata.get('tvmaze_id',''),
'tvmaze_url': metadata.get('tvmaze_url',''),
'lastfm_url': metadata.get('lastfm_url',''),
'trakt_url': metadata.get('trakt_url',''),
'container': session.get('container', media_info.get('container','')),
'bitrate': session.get('bitrate', media_info.get('bitrate','')),
'aspect_ratio': session.get('aspect_ratio', media_info.get('aspect_ratio','')),
'video_codec': session.get('video_codec', media_part_info.get('video_codec','')),
'video_codec_level': session.get('video_codec_level', media_part_info.get('video_codec_level','')),
'video_bitrate': session.get('video_bitrate', media_part_info.get('video_bitrate','')),
'video_bit_depth': session.get('video_bit_depth', media_part_info.get('video_bit_depth','')),
'video_framerate': session.get('video_framerate', media_info.get('video_framerate','')),
'video_ref_frames': session.get('video_ref_frames', media_part_info.get('video_ref_frames','')),
'video_resolution': session.get('video_resolution', media_info.get('video_resolution','')),
'video_height': session.get('height', media_info.get('height','')),
'video_width': session.get('width', media_info.get('width','')),
'video_language': session.get('video_language', media_part_info.get('video_language','')),
'video_language_code': session.get('video_language_code', media_part_info.get('video_language_code','')),
'audio_bitrate': session.get('audio_bitrate', media_part_info.get('audio_bitrate','')),
'audio_bitrate_mode': session.get('audio_bitrate_mode', media_part_info.get('audio_bitrate_mode','')),
'audio_codec': session.get('audio_codec', media_part_info.get('audio_codec','')),
'audio_channels': session.get('audio_channels', media_part_info.get('audio_channels','')),
'audio_channel_layout': session.get('audio_channel_layout', media_part_info.get('audio_channel_layout','')),
'audio_sample_rate': session.get('audio_sample_rate', media_part_info.get('audio_sample_rate','')),
'audio_language': session.get('audio_language', media_part_info.get('audio_language','')),
'audio_language_code': session.get('audio_language_code', media_part_info.get('audio_language_code','')),
'subtitle_codec': session.get('subtitle_codec', media_part_info.get('subtitle_codec','')),
'subtitle_container': session.get('subtitle_container', media_part_info.get('subtitle_container','')),
'subtitle_format': session.get('subtitle_format', media_part_info.get('subtitle_format','')),
'subtitle_forced': session.get('subtitle_forced', media_part_info.get('subtitle_forced','')),
'subtitle_location': session.get('subtitle_location', media_part_info.get('subtitle_location','')),
'subtitle_language': session.get('subtitle_language', media_part_info.get('subtitle_language','')),
'subtitle_language_code': session.get('subtitle_language_code', media_part_info.get('subtitle_language_code','')),
'file': media_part_info.get('file',''),
'file_size': helpers.humanFileSize(media_part_info.get('file_size','')),
'indexes': media_part_info.get('indexes',''),
'section_id': metadata['section_id'],
'rating_key': metadata['rating_key'],
'parent_rating_key': metadata['parent_rating_key'],
'grandparent_rating_key': metadata['grandparent_rating_key'],
'thumb': metadata['thumb'],
'parent_thumb': metadata['parent_thumb'],
'grandparent_thumb': metadata['grandparent_thumb'],
'poster_thumb': poster_thumb
}
available_params = {
# Global paramaters
'plexpy_version': common.VERSION_NUMBER,
'plexpy_branch': plexpy.CONFIG.GIT_BRANCH,
'plexpy_commit': plexpy.CURRENT_VERSION,
'server_name': server_name,
'server_uptime': server_uptime,
'server_version': server_times.get('version', ''),
'action': notify_action.lstrip('on_'),
'datestamp': arrow.now().format(date_format),
'timestamp': arrow.now().format(time_format),
# Stream parameters
'streams': stream_count,
'user_streams': user_stream_count,
'user': notify_params['friendly_name'],
'username': notify_params['user'],
'device': notify_params['device'],
'platform': notify_params['platform'],
'product': notify_params['product'],
'player': notify_params['player'],
'ip_address': notify_params.get('ip_address', 'N/A'),
'stream_duration': stream_duration,
'stream_time': arrow.get(stream_duration * 60).format(duration_format),
'remaining_duration': remaining_duration,
'remaining_time': arrow.get(remaining_duration * 60).format(duration_format),
'progress_duration': view_offset,
'progress_time': arrow.get(view_offset * 60).format(duration_format),
'progress_percent': helpers.get_percent(view_offset, duration),
'transcode_decision': transcode_decision,
'video_decision': notify_params['video_decision'],
'audio_decision': notify_params['audio_decision'],
'subtitle_decision': notify_params['subtitle_decision'],
'quality_profile': notify_params['quality_profile'],
'optimized_version': notify_params['optimized_version'],
'optimized_version_profile': notify_params['optimized_version_profile'],
'synced_version': notify_params['synced_version'],
'stream_local': notify_params['local'],
'stream_location': notify_params['location'],
'stream_bandwidth': notify_params['bandwidth'],
'stream_container': notify_params['stream_container'],
'stream_bitrate': notify_params['stream_bitrate'],
'stream_aspect_ratio': notify_params['stream_aspect_ratio'],
'stream_video_codec': notify_params['stream_video_codec'],
'stream_video_codec_level': notify_params['stream_video_codec_level'],
'stream_video_bitrate': notify_params['stream_video_bitrate'],
'stream_video_bit_depth': notify_params['stream_video_bit_depth'],
'stream_video_framerate': notify_params['stream_video_framerate'],
'stream_video_ref_frames': notify_params['stream_video_ref_frames'],
'stream_video_resolution': notify_params['stream_video_resolution'],
'stream_video_height': notify_params['stream_video_height'],
'stream_video_width': notify_params['stream_video_width'],
'stream_video_language': notify_params['stream_video_language'],
'stream_video_language_code': notify_params['stream_video_language_code'],
'stream_audio_bitrate': notify_params['stream_audio_bitrate'],
'stream_audio_bitrate_mode': notify_params['stream_audio_bitrate_mode'],
'stream_audio_codec': notify_params['stream_audio_codec'],
'stream_audio_channels': notify_params['stream_audio_channels'],
'stream_audio_channel_layout': notify_params['stream_audio_channel_layout'],
'stream_audio_sample_rate': notify_params['stream_audio_sample_rate'],
'stream_audio_language': notify_params['stream_audio_language'],
'stream_audio_language_code': notify_params['stream_audio_language_code'],
'stream_subtitle_codec': notify_params['stream_subtitle_codec'],
'stream_subtitle_container': notify_params['stream_subtitle_container'],
'stream_subtitle_format': notify_params['stream_subtitle_format'],
'stream_subtitle_forced': notify_params['stream_subtitle_forced'],
'stream_subtitle_language': notify_params['stream_subtitle_language'],
'stream_subtitle_language_code': notify_params['stream_subtitle_language_code'],
'stream_subtitle_location': notify_params['stream_subtitle_location'],
'transcode_container': notify_params['transcode_container'],
'transcode_video_codec': notify_params['transcode_video_codec'],
'transcode_video_width': notify_params['transcode_width'],
'transcode_video_height': notify_params['transcode_height'],
'transcode_audio_codec': notify_params['transcode_audio_codec'],
'transcode_audio_channels': notify_params['transcode_audio_channels'],
'transcode_hw_requested': notify_params['transcode_hw_requested'],
'transcode_hw_decoding': notify_params['transcode_hw_decoding'],
'transcode_hw_decode_codec': notify_params['transcode_hw_decode'],
'transcode_hw_decode_title': notify_params['transcode_hw_decode_title'],
'transcode_hw_encoding': notify_params['transcode_hw_encoding'],
'transcode_hw_encode_codec': notify_params['transcode_hw_encode'],
'transcode_hw_encode_title': notify_params['transcode_hw_encode_title'],
'transcode_hw_full_pipeline': notify_params['transcode_hw_full_pipeline'],
'session_key': notify_params['session_key'],
'transcode_key': notify_params['transcode_key'],
'session_id': notify_params['session_id'],
'user_id': notify_params['user_id'],
'machine_id': notify_params['machine_id'],
# Source metadata parameters
'media_type': notify_params['media_type'],
'title': notify_params['full_title'],
'library_name': notify_params['library_name'],
'show_name': show_name,
'episode_name': episode_name,
'artist_name': artist_name,
'album_name': album_name,
'track_name': track_name,
'season_num': season_num,
'season_num00': season_num00,
'episode_num': episode_num,
'episode_num00': episode_num00,
'track_num': track_num,
'track_num00': track_num00,
'year': notify_params['year'],
'release_date': arrow.get(notify_params['originally_available_at']).format(date_format)
if notify_params['originally_available_at'] else '',
'air_date': arrow.get(notify_params['originally_available_at']).format(date_format)
if notify_params['originally_available_at'] else '',
'added_date': arrow.get(notify_params['added_at']).format(date_format)
if notify_params['added_at'] else '',
'updated_date': arrow.get(notify_params['updated_at']).format(date_format)
if notify_params['updated_at'] else '',
'last_viewed_date': arrow.get(notify_params['last_viewed_at']).format(date_format)
if notify_params['last_viewed_at'] else '',
'studio': notify_params['studio'],
'content_rating': notify_params['content_rating'],
'directors': ', '.join(notify_params['directors']),
'writers': ', '.join(notify_params['writers']),
'actors': ', '.join(notify_params['actors']),
'genres': ', '.join(notify_params['genres']),
'summary': notify_params['summary'],
'tagline': notify_params['tagline'],
'rating': notify_params['rating'],
'audience_rating': helpers.get_percent(notify_params['audience_rating'], 10) or '',
'duration': duration,
'poster_title': notify_params['poster_title'],
'poster_url': notify_params['poster_url'],
'plex_url': notify_params['plex_url'],
'imdb_id': notify_params['imdb_id'],
'imdb_url': notify_params['imdb_url'],
'thetvdb_id': notify_params['thetvdb_id'],
'thetvdb_url': notify_params['thetvdb_url'],
'themoviedb_id': notify_params['themoviedb_id'],
'themoviedb_url': notify_params['themoviedb_url'],
'tvmaze_id': notify_params['tvmaze_id'],
'tvmaze_url': notify_params['tvmaze_url'],
'lastfm_url': notify_params['lastfm_url'],
'trakt_url': notify_params['trakt_url'],
'container': notify_params['container'],
'bitrate': notify_params['bitrate'],
'aspect_ratio': notify_params['aspect_ratio'],
'video_codec': notify_params['video_codec'],
'video_codec_level': notify_params['video_codec_level'],
'video_bitrate': notify_params['video_bitrate'],
'video_bit_depth': notify_params['video_bit_depth'],
'video_framerate': notify_params['video_framerate'],
'video_ref_frames': notify_params['video_ref_frames'],
'video_resolution': notify_params['video_resolution'],
'video_height': notify_params['height'],
'video_width': notify_params['width'],
'video_language': notify_params['video_language'],
'video_language_code': notify_params['video_language_code'],
'audio_bitrate': notify_params['audio_bitrate'],
'audio_bitrate_mode': notify_params['audio_bitrate_mode'],
'audio_codec': notify_params['audio_codec'],
'audio_channels': notify_params['audio_channels'],
'audio_channel_layout': notify_params['audio_channel_layout'],
'audio_sample_rate': notify_params['audio_sample_rate'],
'audio_language': notify_params['audio_language'],
'audio_language_code': notify_params['audio_language_code'],
'subtitle_codec': notify_params['subtitle_codec'],
'subtitle_container': notify_params['subtitle_container'],
'subtitle_format': notify_params['subtitle_format'],
'subtitle_forced': notify_params['subtitle_forced'],
'subtitle_location': notify_params['subtitle_location'],
'subtitle_language': notify_params['subtitle_language'],
'subtitle_language_code': notify_params['subtitle_language_code'],
'file': notify_params['file'],
'file_size': helpers.humanFileSize(notify_params['file_size']),
'indexes': notify_params['indexes'],
'section_id': notify_params['section_id'],
'rating_key': notify_params['rating_key'],
'parent_rating_key': notify_params['parent_rating_key'],
'grandparent_rating_key': notify_params['grandparent_rating_key'],
'thumb': notify_params['thumb'],
'parent_thumb': notify_params['parent_thumb'],
'grandparent_thumb': notify_params['grandparent_thumb'],
'poster_thumb': poster_thumb
}
return available_params
@@ -831,8 +847,8 @@ def build_server_notify_params(notify_action=None, **kwargs):
plex_tv = plextv.PlexTV()
server_times = plex_tv.get_server_times()
pms_download_info = kwargs.pop('pms_download_info', {})
plexpy_download_info = kwargs.pop('plexpy_download_info', {})
pms_download_info = defaultdict(str, kwargs.pop('pms_download_info', {}))
plexpy_download_info = defaultdict(str, kwargs.pop('plexpy_download_info', {}))
if server_times:
updated_at = server_times['updated_at']
@@ -841,37 +857,38 @@ def build_server_notify_params(notify_action=None, **kwargs):
logger.error(u"Tautulli NotificationHandler :: Unable to retrieve server uptime.")
server_uptime = 'N/A'
available_params = {# Global paramaters
'plexpy_version': common.VERSION_NUMBER,
'plexpy_branch': plexpy.CONFIG.GIT_BRANCH,
'plexpy_commit': plexpy.CURRENT_VERSION,
'server_name': server_name,
'server_uptime': server_uptime,
'server_version': server_times.get('version',''),
'action': notify_action.split('on_')[-1],
'datestamp': arrow.now().format(date_format),
'timestamp': arrow.now().format(time_format),
# Plex Media Server update parameters
'update_version': pms_download_info.get('version',''),
'update_url': pms_download_info.get('download_url',''),
'update_release_date': arrow.get(pms_download_info.get('release_date','')).format(date_format)
if pms_download_info.get('release_date','') else '',
'update_channel': 'Plex Pass' if plexpy.CONFIG.PMS_UPDATE_CHANNEL == 'plexpass' else 'Public',
'update_platform': pms_download_info.get('platform',''),
'update_distro': pms_download_info.get('distro',''),
'update_distro_build': pms_download_info.get('build',''),
'update_requirements': pms_download_info.get('requirements',''),
'update_extra_info': pms_download_info.get('extra_info',''),
'update_changelog_added': pms_download_info.get('changelog_added',''),
'update_changelog_fixed': pms_download_info.get('changelog_fixed',''),
# Tautulli update parameters
'plexpy_update_version': plexpy_download_info.get('tag_name', ''),
'plexpy_update_tar': plexpy_download_info.get('tarball_url', ''),
'plexpy_update_zip': plexpy_download_info.get('zipball_url', ''),
'plexpy_update_commit': kwargs.pop('plexpy_update_commit', ''),
'plexpy_update_behind': kwargs.pop('plexpy_update_behind', ''),
'plexpy_update_changelog': plexpy_download_info.get('body', '')
}
available_params = {
# Global paramaters
'plexpy_version': common.VERSION_NUMBER,
'plexpy_branch': plexpy.CONFIG.GIT_BRANCH,
'plexpy_commit': plexpy.CURRENT_VERSION,
'server_name': server_name,
'server_uptime': server_uptime,
'server_version': server_times.get('version', ''),
'action': notify_action.lstrip('on_'),
'datestamp': arrow.now().format(date_format),
'timestamp': arrow.now().format(time_format),
# Plex Media Server update parameters
'update_version': pms_download_info['version'],
'update_url': pms_download_info['download_url'],
'update_release_date': arrow.get(pms_download_info['release_date']).format(date_format)
if pms_download_info['release_date'] else '',
'update_channel': 'Beta' if plexpy.CONFIG.PMS_UPDATE_CHANNEL == 'plexpass' else 'Public',
'update_platform': pms_download_info['platform'],
'update_distro': pms_download_info['distro'],
'update_distro_build': pms_download_info['build'],
'update_requirements': pms_download_info['requirements'],
'update_extra_info': pms_download_info['extra_info'],
'update_changelog_added': pms_download_info['changelog_added'],
'update_changelog_fixed': pms_download_info['changelog_fixed'],
# Tautulli update parameters
'plexpy_update_version': plexpy_download_info['tag_name'],
'plexpy_update_tar': plexpy_download_info['tarball_url'],
'plexpy_update_zip': plexpy_download_info['zipball_url'],
'plexpy_update_commit': kwargs.pop('plexpy_update_commit', ''),
'plexpy_update_behind': kwargs.pop('plexpy_update_behind', ''),
'plexpy_update_changelog': plexpy_download_info['body']
}
return available_params
@@ -930,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)
@@ -941,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)
@@ -950,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

@@ -13,6 +13,9 @@
# You should have received a copy of the GNU General Public License
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
import json
import os
import time
import urllib
import plexpy
@@ -519,7 +522,7 @@ class PmsConnect(object):
return output
def get_metadata_details(self, rating_key='', sync_id=''):
def get_metadata_details(self, rating_key='', sync_id='', cache_key=None):
"""
Return processed and validated metadata list for requested item.
@@ -527,19 +530,33 @@ class PmsConnect(object):
Output: array
"""
metadata = {}
if cache_key:
in_file_path = os.path.join(plexpy.CONFIG.CACHE_DIR, 'metadata-sessionKey-%s.json' % cache_key)
try:
with open(in_file_path, 'r') as inFile:
metadata = json.load(inFile)
except IOError as e:
pass
if metadata:
_cache_time = metadata.pop('_cache_time', 0)
# 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:
metadata = self.get_metadata(str(rating_key), output_format='xml')
metadata_xml = self.get_metadata(str(rating_key), output_format='xml')
elif sync_id:
metadata = self.get_sync_item(str(sync_id), output_format='xml')
metadata_xml = self.get_sync_item(str(sync_id), output_format='xml')
try:
xml_head = metadata.getElementsByTagName('MediaContainer')
xml_head = metadata_xml.getElementsByTagName('MediaContainer')
except Exception as e:
logger.warn(u"Tautulli Pmsconnect :: Unable to parse XML for get_metadata_details: %s." % e)
return {}
metadata = {}
for a in xml_head:
if a.getAttribute('size'):
if a.getAttribute('size') != '1':
@@ -1102,7 +1119,7 @@ class PmsConnect(object):
'subtitle_codec': helpers.get_xml_attr(stream, 'codec'),
'subtitle_container': helpers.get_xml_attr(stream, 'container'),
'subtitle_format': helpers.get_xml_attr(stream, 'format'),
'subtitle_forced': 1 if helpers.get_xml_attr(stream, 'forced') == '1' else 0,
'subtitle_forced': int(helpers.get_xml_attr(stream, 'forced') == '1'),
'subtitle_location': 'external' if helpers.get_xml_attr(stream, 'key') else 'embedded',
'subtitle_language': helpers.get_xml_attr(stream, 'language'),
'subtitle_language_code': helpers.get_xml_attr(stream, 'languageCode')
@@ -1111,7 +1128,7 @@ class PmsConnect(object):
parts.append({'id': helpers.get_xml_attr(part, 'id'),
'file': helpers.get_xml_attr(part, 'file'),
'file_size': helpers.get_xml_attr(part, 'size'),
'indexes': 1 if helpers.get_xml_attr(part, 'indexes') == 'sd' else 0,
'indexes': int(helpers.get_xml_attr(part, 'indexes') == 'sd'),
'streams': streams
})
@@ -1131,13 +1148,24 @@ class PmsConnect(object):
'audio_channels': audio_channels,
'audio_channel_layout': common.AUDIO_CHANNELS.get(audio_channels, audio_channels),
'audio_profile': helpers.get_xml_attr(media, 'audioProfile'),
'optimized_version': 1 if helpers.get_xml_attr(media, 'proxyType') == '42' else 0,
'optimized_version': int(helpers.get_xml_attr(media, 'proxyType') == '42'),
'parts': parts
})
metadata['media_info'] = medias
if metadata:
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:
json.dump(metadata, outFile)
except IOError as e:
logger.error(u"Tautulli Pmsconnect :: Unable to create cache file for metadata (sessionKey %s): %s"
% (cache_key, e))
return metadata
else:
return {}
@@ -1299,6 +1327,7 @@ class PmsConnect(object):
# Get the source media type
media_type = helpers.get_xml_attr(session, 'type')
rating_key = helpers.get_xml_attr(session, 'ratingKey')
session_key = helpers.get_xml_attr(session, 'sessionKey')
# Get the user details
user_info = session.getElementsByTagName('User')[0]
@@ -1352,7 +1381,7 @@ class PmsConnect(object):
transcode_speed = helpers.get_xml_attr(transcode_info, 'speed')
transcode_details = {'transcode_key': helpers.get_xml_attr(transcode_info, 'key'),
'transcode_throttled': 1 if helpers.get_xml_attr(transcode_info, 'throttled') == '1' else 0,
'transcode_throttled': int(helpers.get_xml_attr(transcode_info, 'throttled') == '1'),
'transcode_progress': int(round(helpers.cast_to_float(transcode_progress), 0)),
'transcode_speed': str(round(helpers.cast_to_float(transcode_speed), 1)),
'transcode_audio_channels': helpers.get_xml_attr(transcode_info, 'audioChannels'),
@@ -1362,12 +1391,12 @@ class PmsConnect(object):
'transcode_height': helpers.get_xml_attr(transcode_info, 'height'), # Blank but keep backwards compatibility
'transcode_container': helpers.get_xml_attr(transcode_info, 'container'),
'transcode_protocol': helpers.get_xml_attr(transcode_info, 'protocol'),
'transcode_hw_requested': 1 if helpers.get_xml_attr(transcode_info, 'transcodeHwRequested') == '1' else 0,
'transcode_hw_requested': int(helpers.get_xml_attr(transcode_info, 'transcodeHwRequested') == '1'),
'transcode_hw_decode': helpers.get_xml_attr(transcode_info, 'transcodeHwDecoding'),
'transcode_hw_decode_title': helpers.get_xml_attr(transcode_info, 'transcodeHwDecodingTitle'),
'transcode_hw_encode': helpers.get_xml_attr(transcode_info, 'transcodeHwEncoding'),
'transcode_hw_encode_title': helpers.get_xml_attr(transcode_info, 'transcodeHwEncodingTitle'),
'transcode_hw_full_pipeline': 1 if helpers.get_xml_attr(transcode_info, 'transcodeHwFullPipeline') == '1' else 0,
'transcode_hw_full_pipeline': int(helpers.get_xml_attr(transcode_info, 'transcodeHwFullPipeline') == '1'),
'audio_decision': helpers.get_xml_attr(transcode_info, 'audioDecision'),
'video_decision': helpers.get_xml_attr(transcode_info, 'videoDecision'),
'subtitle_decision': helpers.get_xml_attr(transcode_info, 'subtitleDecision'),
@@ -1397,6 +1426,10 @@ class PmsConnect(object):
'throttled': '0' # Keep for backwards compatibility
}
# Check HW decoding/encoding
transcode_details['transcode_hw_decoding'] = int(transcode_details['transcode_hw_decode'].lower() in common.HW_DECODERS)
transcode_details['transcode_hw_encoding'] = int(transcode_details['transcode_hw_encode'].lower() in common.HW_ENCODERS)
# Generate a combined transcode decision value
if transcode_details['video_decision'] == 'transcode' or transcode_details['audio_decision'] == 'transcode':
transcode_decision = 'transcode'
@@ -1489,7 +1522,7 @@ class PmsConnect(object):
subtitle_details = {'stream_subtitle_codec': helpers.get_xml_attr(subtitle_stream_info, 'codec'),
'stream_subtitle_container': helpers.get_xml_attr(subtitle_stream_info, 'container'),
'stream_subtitle_format': helpers.get_xml_attr(subtitle_stream_info, 'format'),
'stream_subtitle_forced': 1 if helpers.get_xml_attr(subtitle_stream_info, 'forced') == '1' else 0,
'stream_subtitle_forced': int(helpers.get_xml_attr(subtitle_stream_info, 'forced') == '1'),
'stream_subtitle_location': helpers.get_xml_attr(subtitle_stream_info, 'location'),
'stream_subtitle_language': helpers.get_xml_attr(subtitle_stream_info, 'language'),
'stream_subtitle_language_code': helpers.get_xml_attr(subtitle_stream_info, 'languageCode'),
@@ -1537,10 +1570,10 @@ class PmsConnect(object):
'stream_duration': helpers.get_xml_attr(stream_media_info, 'duration') or helpers.get_xml_attr(session, 'duration'),
'stream_container_decision': 'direct play' if sync_id else helpers.get_xml_attr(stream_media_parts_info, 'decision').replace('directplay', 'direct play'),
'transcode_decision': transcode_decision,
'optimized_version': 1 if helpers.get_xml_attr(stream_media_info, 'proxyType') == '42' else 0,
'optimized_version': int(helpers.get_xml_attr(stream_media_info, 'proxyType') == '42'),
'optimized_version_title': helpers.get_xml_attr(stream_media_info, 'title'),
'synced_version': 1 if sync_id else 0,
'indexes': 1 if indexes == 'sd' else 0,
'indexes': int(indexes == 'sd'),
'bif_thumb': bif_thumb,
'subtitles': 1 if subtitle_id and subtitle_selected else 0
}
@@ -1609,9 +1642,9 @@ class PmsConnect(object):
part_id = helpers.get_xml_attr(stream_media_parts_info, 'id')
if sync_id:
metadata_details = self.get_metadata_details(sync_id=sync_id)
metadata_details = self.get_metadata_details(sync_id=sync_id, cache_key=session_key)
else:
metadata_details = self.get_metadata_details(rating_key=rating_key)
metadata_details = self.get_metadata_details(rating_key=rating_key, cache_key=session_key)
# Get the media info, fallback to first item if match id is not found
source_medias = metadata_details.pop('media_info', [])
@@ -1724,7 +1757,7 @@ class PmsConnect(object):
optimized_version_profile = ''
# Entire session output (single dict for backwards compatibility)
session_output = {'session_key': helpers.get_xml_attr(session, 'sessionKey'),
session_output = {'session_key': session_key,
'media_type': media_type,
'view_offset': view_offset,
'progress_percent': str(helpers.get_percent(view_offset, stream_details['stream_duration'])),

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.7-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.
@@ -4437,11 +4439,12 @@ class WebInterface(object):
if session_key:
return next((s for s in result['sessions'] if s['session_key'] == session_key), {})
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':
@@ -4452,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',