Compare commits

...

44 Commits

Author SHA1 Message Date
JonnyWong16
4f397b032e v2.1.19-beta 2018-08-19 08:38:39 -07:00
JonnyWong16
3a05b8ec69 Fix switching tray icon on update check 2018-08-18 15:35:25 -07:00
JonnyWong16
84ef02aa03 Add update check to Windows tray icon 2018-08-18 15:30:59 -07:00
JonnyWong16
5d82ed9415 Update newsletter config note 2018-08-18 15:06:49 -07:00
JonnyWong16
524183c2cb Fix spaces in newsletter (Resolves #1302) 2018-08-17 21:08:27 -07:00
JonnyWong16
53b361d410 Allow override for PYTHONPATH in scripts 2018-08-15 20:17:48 -07:00
JonnyWong16
30c7c6592e Merge pull request #1301 from samwiseg00/refactor-oauth-login
Refactor OAuth Login
2018-08-15 20:06:07 -07:00
JonnyWong16
88b0b888a1 Merge pull request #1300 from samwiseg00/fix-db-backup
Fix API creating a backup every sql query
2018-08-15 20:05:48 -07:00
samwiseg00
c2dcd98939 Verify that we are checking for a server 2018-08-15 21:48:25 -04:00
samwiseg00
56e9845b2c Change method for determining server list for OAuth 2018-08-15 21:48:13 -04:00
samwiseg00
6b94292c7e Fix API creating a backup every sql query 2018-08-15 16:31:31 -04:00
JonnyWong16
13dac9c1ea Refactor update check 2018-08-14 19:23:20 -07:00
JonnyWong16
4f4a66f7e7 Restart after chaging system tray setting 2018-08-14 00:14:03 -07:00
JonnyWong16
1bd7cf4d4c Close system tray icon on shutdown 2018-08-13 23:57:26 -07:00
JonnyWong16
b1ec49341e Add Windows system tray icon 2018-08-13 19:53:15 -07:00
JonnyWong16
aeccc2db71 Fix incorrect HTTP_ROOT when launching browser 2018-08-13 18:54:12 -07:00
JonnyWong16
6be5397a2d Decode script args before formatting 2018-08-13 09:34:31 -07:00
JonnyWong16
427201a4ce Add recently added XML shortcut 2018-08-12 14:32:45 -07:00
JonnyWong16
5736e12bc3 Format Webhook data strings only 2018-08-12 10:54:39 -07:00
JonnyWong16
4648e3df5f Add webhook notification agent 2018-08-12 10:31:27 -07:00
JonnyWong16
9dbb681f22 Add donate button to Tautulli updated modal 2018-08-11 09:50:59 -07:00
JonnyWong16
658260f1f6 Add x264 to hardware encoders 2018-08-10 22:44:45 -07:00
JonnyWong16
f93e745f0b Fix retrieving email msg id 2018-07-29 20:34:35 -07:00
JonnyWong16
0821c14aae Add option for threaded newsletter emails 2018-07-29 10:28:34 -07:00
JonnyWong16
1b216a35d4 Remove Notify My Android 2018-07-28 11:01:52 -07:00
JonnyWong16
7b4eadb140 Update systemd script instructions 2018-07-28 09:35:23 -07:00
JonnyWong16
a8a676b794 v2.1.18 2018-07-27 13:53:45 -07:00
JonnyWong16
2f40850100 Fix search bar width (Fixes Tautulli/Tautulli-Issues#104) 2018-07-26 17:25:31 -07:00
JonnyWong16
f16560cb40 Fix activity progress bar showing incorrect 100% 2018-07-25 18:48:33 -07:00
JonnyWong16
ab92e48d2e Fix auto resizing textareas scrolling to the top on focus 2018-07-25 18:39:40 -07:00
JonnyWong16
ce2982d948 Skip formatting bad parameters in notification text 2018-07-25 16:23:01 -07:00
JonnyWong16
89d1a5782a v2.1.17-beta 2018-07-22 17:45:02 -07:00
JonnyWong16
97cf2ebe19 Make monitor websocket ping/pong an advanced config option 2018-07-22 17:39:21 -07:00
JonnyWong16
4ef36a464a Set datatables save state duration to indefinitely 2018-07-22 17:35:10 -07:00
JonnyWong16
54ec9ad7da Remove unused libs 2018-07-22 13:54:07 -07:00
JonnyWong16
bfdfdaaad1 Image alt text to Tautulli 2018-07-17 09:44:00 -07:00
JonnyWong16
5bd51b2a17 Don't join empty paths 2018-07-16 09:36:13 -07:00
JonnyWong16
35778cfe72 Use os.pathsep for PYTHONPATH 2018-07-16 09:02:17 -07:00
JonnyWong16
f81649c4d3 Update nullrefer to HTTPS 2018-07-10 15:46:01 -07:00
JonnyWong16
59162713e7 Fix ajax loader message refresh icon spacing 2018-07-10 08:51:55 -07:00
JonnyWong16
188b728dd0 Fix save settings loader 2018-07-10 08:50:56 -07:00
JonnyWong16
3446f5543d Check local server directly 2018-07-10 08:12:12 -07:00
JonnyWong16
ab5384cfdf Discover localhost server 2018-07-09 19:31:11 -07:00
JonnyWong16
e567134ee1 Use default selected stream for media info in notifications 2018-07-06 19:41:03 -07:00
54 changed files with 1205 additions and 3213 deletions

View File

@@ -1,5 +1,42 @@
# Changelog
## v2.1.19-beta (2018-08-19)
* Notifications:
* New: Added Webhook notification agent.
* Fix: Scripts failing due to unicode characters in substituted script arguments.
* Change: Ability to override PYTHONPATH for scripts.
* Remove: Notify My Android notification agent.
* Newsletters:
* New: Added option for threaded newsletter emails.
* Fix: Missing space in newsletter format.
* UI:
* New: Added Windows system tray icon.
* Fix: Plex OAuth not working with Plex remote access disabled. (Thanks @samwiseg00)
* API:
* Fix: SQL command creating a database backup every time. (Thanks @samwiseg00)
## v2.1.18 (2018-07-27)
* Monitoring:
* Fix: Progress bar on activity cards showing incorrect 100% when starting a stream.
* Notifications:
* Fix: Notification text boxes scrolling to top when inputting text.
* Change: Skip formatting invalid notification parameters instead of returning default text.
* UI:
* Fix: Padding around search bar causing the navigation bar to break on smaller screens.
## v2.1.17-beta (2018-07-22)
* Notifications:
* Change: Use default selected stream for media info in notifications.
* UI:
* New: Automatically discover localhost Plex servers in server selection dropdown.
* Change: Save Datatables state indefinitely.
## v2.1.16-beta (2018-07-06)
* Monitoring:

View File

@@ -204,10 +204,10 @@ def main():
# Force the http port if neccessary
if args.port:
http_port = args.port
logger.info('Using forced web server port: %i', http_port)
plexpy.HTTP_PORT = args.port
logger.info('Using forced web server port: %i', plexpy.HTTP_PORT)
else:
http_port = int(plexpy.CONFIG.HTTP_PORT)
plexpy.HTTP_PORT = int(plexpy.CONFIG.HTTP_PORT)
# Check if pyOpenSSL is installed. It is required for certificate generation
# and for CherryPy.
@@ -221,7 +221,7 @@ def main():
# Try to start the server. Will exit here is address is already in use.
web_config = {
'http_port': http_port,
'http_port': plexpy.HTTP_PORT,
'http_host': plexpy.CONFIG.HTTP_HOST,
'http_root': plexpy.CONFIG.HTTP_ROOT,
'http_environment': plexpy.CONFIG.HTTP_ENVIRONMENT,
@@ -238,8 +238,12 @@ def main():
# Open webbrowser
if plexpy.CONFIG.LAUNCH_BROWSER and not args.nolaunch and not plexpy.DEV:
plexpy.launch_browser(plexpy.CONFIG.HTTP_HOST, http_port,
plexpy.CONFIG.HTTP_ROOT)
plexpy.launch_browser(plexpy.CONFIG.HTTP_HOST, plexpy.HTTP_PORT,
plexpy.HTTP_ROOT)
# Windows system tray icon
if os.name == 'nt' and plexpy.CONFIG.WIN_SYS_TRAY:
plexpy.win_system_tray()
# Wait endlessy for a signal to happen
while True:

View File

@@ -43,18 +43,18 @@
<div class="container">
<div id="ajaxMsg" class="ajaxMsg"></div>
% if _session['user_group'] == 'admin':
% if plexpy.CONFIG.CHECK_GITHUB and not plexpy.CURRENT_VERSION:
% if plexpy.CONFIG.CHECK_GITHUB and plexpy.UPDATE_AVAILABLE is None:
<div id="updatebar" style="display: none;">
You are running an unknown version of Tautulli.<br />
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
</div>
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.COMMITS_BEHIND > 0 and plexpy.common.BRANCH in ('master', 'beta') and plexpy.common.RELEASE != plexpy.LATEST_RELEASE:
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.UPDATE_AVAILABLE == 'release':
<div id="updatebar" style="display: none;">
A <a href="${anon_url('https://github.com/%s/%s/releases/tag/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.LATEST_RELEASE))}" target="_blank">
new release (${plexpy.LATEST_RELEASE})</a> of Tautulli is available!<br />
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
</div>
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.COMMITS_BEHIND > 0 and plexpy.CURRENT_VERSION != plexpy.LATEST_VERSION and plexpy.INSTALL_TYPE != 'win':
% elif plexpy.CONFIG.CHECK_GITHUB and plexpy.UPDATE_AVAILABLE == 'commit':
<div id="updatebar" style="display: none;">
A <a href="${anon_url('https://github.com/%s/%s/compare/%s...%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CURRENT_VERSION, plexpy.LATEST_VERSION))}" target="_blank">
newer version</a> of Tautulli is available!<br />
@@ -75,7 +75,7 @@
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="home" title="Tautulli">
<img src="${http_root}images/logo-tautulli-45.png" height="45" alt="PlexPy">
<img src="${http_root}images/logo-tautulli-45.png" height="45" alt="Tautulli">
</a>
</div>
<div class="collapse navbar-collapse navbar-right" id="navbar-collapse-1">
@@ -140,7 +140,7 @@
<li><a href="#" data-target="#donate-modal" data-toggle="modal"><i class="fa fa-fw fa-heart"></i> Donate</a></li>
<li role="separator" class="divider"></li>
% if plexpy.CONFIG.CHECK_GITHUB:
<li><a href="#" id="nav-update"><i class="fa fa-fw fa-arrow-circle-up"></i> Check for Updates</a></li>
<li><a href="#" id="nav-update"><i class="fa fa-fw fa-arrow-alt-circle-up"></i> Check for Updates</a></li>
% endif
<li><a href="#" id="nav-restart"><i class="fa fa-fw fa-refresh"></i> Restart</a></li>
<li><a href="#" id="nav-shutdown"><i class="fa fa-fw fa-power-off"></i> Shutdown</a></li>
@@ -362,7 +362,7 @@ ${next.modalIncludes()}
$('#nav-update').click(function () {
$(this).html('<i class="fa fa-fw fa-spin fa-refresh"></i> Checking');
checkUpdate(function () { $('#nav-update').html('<i class="fa fa-fw fa-arrow-circle-up"></i> Check for Updates'); });
checkUpdate(function () { $('#nav-update').html('<i class="fa fa-fw fa-arrow-alt-circle-up"></i> Check for Updates'); });
});
$('#donation_type a.crypto-donation').on('shown.bs.tab', function () {

View File

@@ -22,11 +22,11 @@ DOCUMENTATION :: END
% if plexpy.CURRENT_VERSION:
<tr>
<td>Git Branch:</td>
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/tree/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CONFIG.GIT_BRANCH))}">${plexpy.CONFIG.GIT_BRANCH}</a></td>
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/tree/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CONFIG.GIT_BRANCH))}" target="_blank">${plexpy.CONFIG.GIT_BRANCH}</a></td>
</tr>
<tr>
<td>Git Commit Hash:</td>
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/commit/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CURRENT_VERSION))}">${plexpy.CURRENT_VERSION}</a></td>
<td><a class="no-highlight" href="${anon_url('https://github.com/%s/%s/commit/%s' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO, plexpy.CURRENT_VERSION))}" target="_blank">${plexpy.CURRENT_VERSION}</a></td>
</tr>
% endif
<tr>

View File

@@ -3288,7 +3288,7 @@ pre::-webkit-scrollbar-thumb {
}
}
#search_form {
width: 300px;
width: 270px;
padding: 8px 15px;
}
#search_form span.input-textbox {

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@@ -100,7 +100,7 @@
<div class="row">
<div class="col-md-12">
<div class="home-padded-header padded-header">
<h3 class="pull-left">Recently Added</h3>
<h3 class="pull-left"><span id="recently-added-xml">Recently Added</span></h3>
<ul class="nav nav-header nav-dashboard pull-right" style="margin-top: -3px;">
<li>
<a href="#" id="recently-added-page-left" class="paginate btn-gray disabled" data-id="+1"><i class="fa fa-lg fa-chevron-left"></i></a>
@@ -169,6 +169,7 @@
<div class="modal-body">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger" data-target="#donate-modal" data-toggle="modal" style="float: left;"><i class="fa fa-fw fa-heart"></i> Donate</button>
<input type="button" class="btn btn-bright" data-dismiss="modal" value="Close">
</div>
</div>
@@ -617,7 +618,8 @@
if ($(this).data('state') === 'playing' && $(this).data('view_offset') >= 0) {
var view_offset = parseInt($(this).data('view_offset'));
var stream_duration = parseInt($(this).data('stream_duration'));
var progress_percent = Math.min(Math.floor(view_offset / stream_duration * 100) || 100, 100);
var progress_percent = Math.floor(view_offset / stream_duration * 100);
progress_percent = (progress_percent >= 0) ? Math.min(progress_percent, 100) : 100;
$(this).width(progress_percent - 3 + '%').html(progress_percent + '%')
.attr('data-original-title', 'Stream Progress ' + progress_percent + '%')
.data('view_offset', Math.min(view_offset + 1000, stream_duration));
@@ -845,6 +847,10 @@
});
$('#recently-added-count').tooltip({ container: 'body', placement: 'top', html: true });
$('#recently-added-xml').on('tripleclick', function () {
openPlexXML('/library/recentlyAdded', false, {'X-Plex-Container-Start': 0, 'X-Plex-Container-Size': recently_added_count});
});
</script>
% endif
% if _session['user_group'] == 'admin' and config['update_show_changelog']:

View File

@@ -37,7 +37,7 @@ function showMsg(msg, loader, timeout, ms, error) {
}
var message = $("<div class='msg'>" + msg + "</div>");
if (loader) {
message = $("<div class='msg'><i class='fa fa-refresh fa-spin'></i> " + msg + "</div>");
message = $("<div class='msg'><i class='fa fa-refresh fa-spin'></i>&nbsp; " + msg + "</div>");
feedback.css("padding", "14px 10px");
}
if (error) {
@@ -73,9 +73,9 @@ function confirmAjaxCall(url, msg, data, loader_msg, callback) {
var result = $.parseJSON(xhr.responseText);
var msg = result.message;
if (result.result == 'success') {
showMsg('<i class="fa fa-check"></i> ' + msg, false, true, 5000);
showMsg('<i class="fa fa-check"></i>&nbsp; ' + msg, false, true, 5000);
} else {
showMsg('<i class="fa fa-times"></i> ' + msg, false, true, 5000, true);
showMsg('<i class="fa fa-times"></i>&nbsp; ' + msg, false, true, 5000, true);
}
if (typeof callback === "function") {
callback(result);
@@ -103,7 +103,7 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
dataString = $(formID).serialize();
}
// Loader Image
var loader = $("<i class='fa fa-refresh fa-spin ajaxLoader-" + url +"></i>");
var loader = $("<div class='msg ajaxLoader-" + url +"'><i class='fa fa-refresh fa-spin'></i>&nbsp; Saving...</div>");
// Data Success Message
var dataSucces = $(elem).data('success');
if (typeof dataSucces === "undefined") {
@@ -117,8 +117,8 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
dataError = "There was an error";
}
// Get Success & Error message from inline data, else use standard message
var succesMsg = $("<div class='msg'><i class='fa fa-check'></i> " + dataSucces + "</div>");
var errorMsg = $("<div class='msg'><i class='fa fa-exclamation-triangle'></i> " + dataError + "</div>");
var succesMsg = $("<div class='msg'><i class='fa fa-check'></i>&nbsp; " + dataSucces + "</div>");
var errorMsg = $("<div class='msg'><i class='fa fa-exclamation-triangle'></i>&nbsp; " + dataError + "</div>");
// Check if checkbox is selected
if (form) {
if ($('td#select input[type=checkbox]').length > 0 && !$('td#select input[type=checkbox]').is(':checked') ||
@@ -187,7 +187,7 @@ function doAjaxCall(url, elem, reload, form, showMsg, callback) {
},
complete: function (jqXHR, textStatus) {
// Remove loaders and stuff, ajax request is complete!
feedback.remove('.ajaxLoader-' + url);
$('.ajaxLoader-' + url).remove();
if (typeof callback === "function") {
callback(jqXHR);
}

View File

@@ -24,6 +24,7 @@ history_table_options = {
},
"pagingType": "full_numbers",
"stateSave": true,
"stateDuration": 0,
"processing": false,
"serverSide": true,
"pageLength": 25,
@@ -289,7 +290,7 @@ history_table_options = {
' (filtered from ' + settings.json.total_duration + ' total)</span>');
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0);
$('[data-toggle="tooltip"]').tooltip('destroy');
$('[data-toggle="popover"]').popover('destroy');

View File

@@ -148,7 +148,7 @@ history_table_modal_options = {
});
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
}
}

View File

@@ -17,6 +17,7 @@ libraries_list_table_options = {
"pageLength": 25,
"order": [ 2, 'asc'],
"stateSave": true,
"stateDuration": 0,
"pagingType": "full_numbers",
"autoWidth": false,
"scrollX": true,
@@ -238,7 +239,7 @@ libraries_list_table_options = {
}
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
},
"rowCallback": function (row, rowData) {

View File

@@ -10,6 +10,7 @@ login_log_table_options = {
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
},
"stateSave": true,
"stateDuration": 0,
"pagingType": "full_numbers",
"processing": false,
"serverSide": true,
@@ -110,7 +111,7 @@ login_log_table_options = {
},
"preDrawCallback": function (settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
}
};

View File

@@ -6,6 +6,7 @@ var log_table_options = {
"order": [ 0, 'desc'],
"pageLength": 50,
"stateSave": true,
"stateDuration": 0,
"language": {
"search": "Search: ",
"lengthMenu": "Show _MENU_ lines per page",
@@ -39,7 +40,7 @@ var log_table_options = {
$('#ajaxMsg').fadeOut();
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
}
}

View File

@@ -25,6 +25,7 @@ media_info_table_options = {
},
"pagingType": "full_numbers",
"stateSave": true,
"stateDuration": 0,
"processing": false,
"serverSide": true,
"pageLength": 25,
@@ -299,7 +300,7 @@ media_info_table_options = {
' (filtered from ' + humanFileSize(settings.json.total_file_size) + ')</span>');
},
"preDrawCallback": function (settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
},
"rowCallback": function (row, rowData, rowIndex) {

View File

@@ -6,6 +6,7 @@ newsletter_log_table_options = {
"order": [ 0, 'desc'],
"pageLength": 50,
"stateSave": true,
"stateDuration": 0,
"language": {
"search":"Search: ",
"lengthMenu": "Show _MENU_ lines per page",
@@ -140,7 +141,7 @@ newsletter_log_table_options = {
});
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
}
};

View File

@@ -6,6 +6,7 @@ notification_log_table_options = {
"order": [ 0, 'desc'],
"pageLength": 50,
"stateSave": true,
"stateDuration": 0,
"language": {
"search":"Search: ",
"lengthMenu": "Show _MENU_ lines per page",
@@ -110,7 +111,7 @@ notification_log_table_options = {
});
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
}
};

View File

@@ -6,6 +6,7 @@ var plex_log_table_options = {
"order": [ 0, 'desc'],
"pageLength": 50,
"stateSave": true,
"stateDuration": 0,
"language": {
"search": "Search: ",
"lengthMenu": "Show _MENU_ lines per page",
@@ -39,7 +40,7 @@ var plex_log_table_options = {
$('#ajaxMsg').fadeOut();
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
}
}

View File

@@ -7,6 +7,7 @@ sync_table_options = {
"order": [ [ 0, 'desc'], [ 1, 'asc'], [2, 'asc'] ],
"pageLength": 25,
"stateSave": true,
"stateDuration": 0,
"language": {
"search": "Search: ",
"lengthMenu": "Show _MENU_ lines per page",
@@ -147,7 +148,7 @@ sync_table_options = {
},
"preDrawCallback": function (settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
},
"rowCallback": function (row, rowData, rowIndex) {

View File

@@ -10,6 +10,7 @@ user_ip_table_options = {
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
},
"stateSave": true,
"stateDuration": 0,
"pagingType": "full_numbers",
"processing": false,
"serverSide": true,
@@ -141,7 +142,7 @@ user_ip_table_options = {
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
}
}

View File

@@ -34,6 +34,7 @@ users_list_table_options = {
"pageLength": 25,
"order": [ 2, 'asc'],
"stateSave": true,
"stateDuration": 0,
"pagingType": "full_numbers",
"autoWidth": false,
"scrollX": true,
@@ -240,7 +241,7 @@ users_list_table_options = {
}
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbspFetching rows...";
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
},
"rowCallback": function (row, rowData) {

View File

@@ -42,7 +42,7 @@
<div class="row">
<div class="login-container">
<div class="login-logo">
<img src="${http_root}images/logo-tautulli-100.png" height="100" alt="PlexPy">
<img src="${http_root}images/logo-tautulli-100.png" height="100" alt="Tautulli">
</div>
<div class="row">
<div class="col-sm-6 col-sm-offset-3">

View File

@@ -21,7 +21,7 @@
<div class="row">
<div class="login-container">
<div class="newsletter-logo">
<img src="${http_root}images/newsletter/newsletter-header.png" height="100" alt="PlexPy">
<img src="${http_root}images/newsletter/newsletter-header.png" height="100" alt="Tautulli">
</div>
<div class="row">
<div class="col-sm-6 col-sm-offset-3">

View File

@@ -173,7 +173,11 @@
<input type="text" class="form-control" id="id_name" name="id_name" value="${newsletter['id_name']}" size="30">
</div>
</div>
<p class="help-block">Optional: Enter a unique ID name to create a static URL to the last sent scheduled newsletter at <span class="inline-pre">${http_root}newsletter/id/&lt;id_name&gt;</span>. Only letters (a-z), numbers (0-9), underscores (_) and hyphens (-) are allowed. Leave blank to disable.</p>
<p class="help-block">
Optional: Enter a unique ID name to create a static URL to the <em>last sent scheduled newsletter</em> at <span class="inline-pre">${http_root}newsletter/id/&lt;id_name&gt;</span>.
Only letters (a-z), numbers (0-9), underscores (_) and hyphens (-) are allowed. Leave blank to disable.<br>
Note: Test newsletters are not considered as scheduled newsletters.
</p>
</div>
<div class="form-group">
<label for="friendly_name">Description</label>
@@ -218,6 +222,13 @@
<input type="hidden" id="newsletter_config_formatted" name="newsletter_config_formatted" value="${newsletter['config']['formatted']}">
</div>
<div class="form-group" id="email_notifier_select">
<div class="checkbox">
<label>
<input type="checkbox" id="newsletter_config_threaded_checkbox" data-id="newsletter_config_threaded" class="checkboxes" value="1" ${checked(newsletter['config']['threaded'])}> Enable Grouped Email Thread
</label>
<p class="help-block">Enable to group this newsletter together in a single Email thread. Disable to send a new Email for each newsletter.</p>
<input type="hidden" id="newsletter_config_threaded" name="newsletter_config_threaded" value="${newsletter['config']['threaded']}">
</div>
<label for="newsletter_email_notifier_id">Email Notification Agent</label>
<div class="row">
<div class="col-md-12">
@@ -766,9 +777,12 @@
// auto resizing textarea for custom notification message body
$('textarea[data-autoresize]').each(function () {
var modal_body = $(this).closest('.modal-body');
var offset = this.offsetHeight - this.clientHeight;
var resizeTextarea = function (el) {
var modal_offset = modal_body.scrollTop();
$(el).css('height', 'auto').css('height', el.scrollHeight + offset);
modal_body.scrollTop(modal_offset);
};
$(this).on('focus keyup input', function () { resizeTextarea(this); }).removeAttr('data-autoresize');
});

View File

@@ -21,7 +21,13 @@
<li role="presentation" class="active"><a href="#tabs-notifier_config" aria-controls="tabs-notifier_config" role="tab" data-toggle="tab">Configuration</a></li>
<li role="presentation"><a href="#tabs-notify_triggers" aria-controls="tabs-notify_triggers" role="tab" data-toggle="tab">Triggers</a></li>
<li role="presentation"><a href="#tabs-notify_conditions" aria-controls="tabs-notify_conditions" role="tab" data-toggle="tab">Conditions</a></li>
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">${'Arguments' if notifier['agent_name'] == 'scripts' else 'Text'}</a></li>
% if notifier['agent_name'] == 'scripts':
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">Arguments</a></li>
% elif notifier['agent_name'] == 'webhook':
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">Data</a></li>
% else:
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">Text</a></li>
% endif
<li role="presentation"><a href="#tabs-test_notifications" aria-controls="tabs-test_notifications" role="tab" data-toggle="tab">Test Notifications</a></li>
</ul>
</div>
@@ -184,6 +190,8 @@
<p class="help-block">
% if notifier['agent_name'] == 'scripts':
Set the custom arguments passed to the script for each type of notification.
% elif notifier['agent_name'] == 'webhook':
Set the custom JSON data sent to the webhook for each type of notification.
% else:
Set the custom formatted text for each type of notification.
% endif
@@ -225,6 +233,32 @@
</ul>
</li>
% endfor
% elif notifier['agent_name'] == 'webhook':
% for action in available_notification_actions:
<li>
<div class="link">
<span class="toggle-left"><i class="fa ${action['icon']} fa-fw"></i></span>&nbsp;
${action['label']}
<span class="toggle-right"><i class="fa fa-chevron-down"></i></span>
</div>
<ul class="submenu">
<li>
<div class="form-group">
<label for="${action['name']}_body">JSON Data</label>
<textarea class="form-control" id="${action['name']}_body" name="${action['name']}_body" data-parsley-trigger="change" data-autoresize required>${notifier['notify_text'][action['name']]['body']}</textarea>
<p class="help-block">Set custom JSON data.</p>
</div>
<div class="form-group">
<div class="row">
<div class="col-md-12">
<input type="button" class="btn btn-bright notifier-text-preview" data-action="${action['name']}" value="Preview JSON Data">
</div>
</div>
</div>
</li>
</ul>
</li>
% endfor
% else:
% for action in available_notification_actions:
<li>
@@ -291,6 +325,16 @@
</div>
<p class="help-block">Set custom arguments passed to the script.</p>
</div>
% elif notifier['agent_name'] == 'webhook':
<div class="form-group">
<label for="test_body">JSON Data</label>
<div class="row">
<div class="col-md-12">
<textarea class="form-control" id="test_body" name="test_body" data-autoresize></textarea>
</div>
</div>
<p class="help-block">Set custom JSON data sent to the webhook.</p>
</div>
% else:
<div class="form-group">
<label for="test_subject">Subject Line</label>
@@ -305,7 +349,7 @@
<label for="test_body">Message Body</label>
<div class="row">
<div class="col-md-12">
<input class="form-control" type="text" id="test_body" name="test_body" value="Test notification">
<textarea class="form-control" id="test_body" name="test_body" data-autoresize>Test Notification</textarea>
</div>
</div>
<p class="help-block">Set a custom body.</p>
@@ -811,9 +855,12 @@
// auto resizing textarea for custom notification message body
$('textarea[data-autoresize]').each(function () {
var modal_body = $(this).closest('.modal-body');
var offset = this.offsetHeight - this.clientHeight;
var resizeTextarea = function (el) {
var modal_offset = modal_body.scrollTop();
$(el).css('height', 'auto').css('height', el.scrollHeight + offset);
modal_body.scrollTop(modal_offset);
};
$(this).on('focus keyup input', function () { resizeTextarea(this); }).removeAttr('data-autoresize');
});

View File

@@ -9,7 +9,9 @@
% for item in text:
<div style="padding-bottom: 10px;">
<h4>${item['media_type'].capitalize()}</h4>
% if agent != 'webhook':
<pre>${item['subject']}</pre>
% endif
% if agent != 'scripts':
<pre>${item['body']}</pre>
% endif

View File

@@ -33,7 +33,7 @@
<button id="menu_link_show_advanced_settings" class="btn btn-dark"><i class="fa fa-wrench"></i> Show Advanced</button>
% endif
% if config['check_github']:
<button id="menu_link_update_check" class="btn btn-dark"><i class="fa fa-arrow-circle-up"></i> Check for Updates</button>
<button id="menu_link_update_check" class="btn btn-dark"><i class="fa fa-arrow-alt-circle-up"></i> Check for Updates</button>
% endif
<button id="menu_link_restart" class="btn btn-dark"><i class="fa fa-refresh"></i> Restart</button>
<button id="menu_link_shutdown" class="btn btn-dark"><i class="fa fa-power-off"></i> Shutdown</button>
@@ -430,6 +430,14 @@
</div>
<p class="help-block">Note: Web interface changes require a restart.</p>
% if os.name == 'nt':
<div class="checkbox">
<label>
<input type="checkbox" class="http-settings" name="win_sys_tray" id="win_sys_tray" value="1" ${config['win_sys_tray']}> Enable System Tray Icon
</label>
<p class="help-block">Show Tautulli shortcut in the system tray.</p>
</div>
% endif
<div class="checkbox">
<label>
<input type="checkbox" name="launch_browser" id="launch_browser" value="1" ${config['launch_browser']}> Launch Browser on Startup
@@ -650,12 +658,20 @@
</div>
<div class="form-group has-feedback" id="pms_ip_group">
<label for="pms_ip">Plex IP Address or Hostname</label>
<label for="pms_ip_selectize">Plex IP Address or Hostname</label>
<div class="row">
<div class="col-md-9" id="selectize-pms-ip-container">
<div class="input-group">
<select class="form-control pms-settings selectize-pms-ip" id="pms_ip" name="pms_ip" data-parsley-trigger="change" aria-describedby="server-verified" data-parsley-errors-container="#pms_ip_error" required>
<option value="${config['pms_ip']}" selected>${config['pms_ip']}</option>
<select class="form-control pms-settings selectize-pms-ip" id="pms_ip_selectize" data-parsley-trigger="change" aria-describedby="server-verified" data-parsley-errors-container="#pms_ip_error" required>
<option value="${config['pms_ip']}:${config['pms_port']}"
data-identifier="${config['pms_identifier']}"
data-ip="${config['pms_ip']}"
data-port="${config['pms_port']}"
data-local="${int(not int(config['pms_is_remote']))}"
data-ssl="${config['pms_ssl']}"
data-is_cloud="${config['pms_is_cloud']}"
data-label="${config['pms_name'] or 'Local'}"
selected>${config['pms_ip']}</option>
</select>
<span class="input-group-btn">
<button class="btn btn-form" type="button" id="verify_server_button">Verify Server</button>
@@ -738,6 +754,7 @@
</p>
</div>
<input type="hidden" id="pms_ip" name="pms_ip" value="${config['pms_ip']}">
<input type="hidden" id="pms_is_cloud" name="pms_is_cloud" value="${config['pms_is_cloud']}">
<input type="checkbox" name="server_changed" id="server_changed" value="1" style="display: none;">
@@ -1920,7 +1937,7 @@ $(document).ready(function() {
$('#menu_link_update_check').click(function() {
$(this).html('<i class="fa fa-spin fa-refresh"></i> Checking').prop('disabled', true);
checkUpdate(function () {
$('#menu_link_update_check').html('<i class="fa fa-arrow-circle-up"></i> Check for Updates')
$('#menu_link_update_check').html('<i class="fa fa-arrow-alt-circle-up"></i> Check for Updates')
.prop('disabled', false);
});
});
@@ -2053,7 +2070,7 @@ $(document).ready(function() {
}
});
var $select_pms = $('#pms_ip').selectize({
var $select_pms = $('#pms_ip_selectize').selectize({
createOnBlur: true,
openOnFocus: true,
maxItems: 1,
@@ -2064,13 +2081,19 @@ $(document).ready(function() {
dropdownParent: '#selectize-pms-ip-container',
render: {
item: function (item, escape) {
if (!item.label) {
$.extend(item,
$(this.revertSettings.$children)
.filter('[value=' + item.value + ']').data()
);
}
var label = item.label || item.value;
var caption = item.label ? item.value : null;
return '<div data-ssl="' + item.httpsRequired +
'" data-local="' + item.local +
'" data-identifier="' + item.clientIdentifier +
var caption = item.label ? item.ip : null;
return '<div data-identifier="' + item.clientIdentifier +
'" data-ip="' + item.ip +
'" data-port="' + item.port +
'" data-local="' + item.local +
'" data-ssl="' + item.httpsRequired +
'" data-is_cloud="' + item.is_cloud +
'" data-label="' + item.label + '">' +
'<span class="item-text">' + escape(label) + '</span>' +
@@ -2080,11 +2103,11 @@ $(document).ready(function() {
option: function (item, escape) {
var label = item.label || item.value;
var caption = item.label ? item.value : null;
return '<div data-ssl="' + item.httpsRequired +
'" data-local="' + item.local +
'" data-identifier="' + item.clientIdentifier +
return '<div data-identifier="' + item.clientIdentifier +
'" data-ip="' + item.ip +
'" data-port="' + item.port +
'" data-local="' + item.local +
'" data-ssl="' + item.httpsRequired +
'" data-is_cloud="' + item.is_cloud +
'" data-label="' + item.label + '">' +
escape(label) +
@@ -2095,15 +2118,24 @@ $(document).ready(function() {
create: function(input) {
return {label: '', value: input};
},
onInitialize: function () {
var s = this;
this.revertSettings.$children.each(function () {
$.extend(s.options[this.value], $(this).data());
});
},
onChange: function (item) {
var pms_ip_selected = this.getItem(item)[0];
var identifier = $(pms_ip_selected).data('identifier');
var ip = $(pms_ip_selected).data('ip');
var port = $(pms_ip_selected).data('port');
var local = $(pms_ip_selected).data('local');
var ssl = $(pms_ip_selected).data('ssl');
var is_cloud = $(pms_ip_selected).data('is_cloud');
var value = $(pms_ip_selected).data('value');
$("#pms_identifier").val(identifier !== 'undefined' ? identifier : '');
$('#pms_ip').val(ip !== 'undefined' ? ip : value);
$('#pms_port').val(port !== 'undefined' ? port : 32400);
$('#pms_is_remote_checkbox').prop('checked', (local !== 'undefined' && local === 0));
$('#pms_is_remote').val(local !== 'undefined' && local === 0 ? 1 : 0);
@@ -2128,9 +2160,10 @@ $(document).ready(function() {
},
success: function (result) {
if (result) {
var existing_value = $('#pms_ip').val();
var existing_ip = $('#pms_ip').val();
var existing_port = $('#pms_port').val();
result.forEach(function (item) {
if (item.value === existing_value) {
if (item.ip === existing_ip && item.port === existing_port) {
select_pms.updateOption(item.value, item);
} else {
select_pms.addOption(item);

View File

@@ -203,8 +203,8 @@ DOCUMENTATION :: END
$('#confirm-modal-update').modal();
$('#confirm-modal-update').one('click', '#confirm-update', function () {
$(this).prop('disabled', true);
var msg = '<i class="fa fa-refresh fa-spin"></i>&nbspUpdating database...'
showMsg(msg, false, false, 0)
var msg = '<i class="fa fa-refresh fa-spin"></i>&nbsp; Updating database...';
showMsg(msg, false, false, 0);
$.ajax({
url: 'update_metadata_details',

View File

@@ -52,7 +52,7 @@
<form>
<div class="wizard-card" data-cardname="card1">
<div style="float: right;">
<img src="${http_root}images/logo-tautulli-45.png" height="45" alt="PlexPy">
<img src="${http_root}images/logo-tautulli-45.png" height="45" alt="Tautulli">
</div>
<h3 style="line-height: 50px;">Welcome!</h3>
<div class="wizard-input-section">
@@ -86,11 +86,19 @@
</p>
</div>
<div class="wizard-input-section">
<label for="pms_ip">Plex IP or Hostname</label>
<label for="pms_ip_selectize">Plex IP Address or Hostname</label>
<div class="row">
<div class="col-xs-12">
<select class="form-control pms-settings selectize-pms-ip" id="pms_ip" name="pms_ip">
<option value="${config['pms_ip']}" selected>${config['pms_ip']}</option>
<select class="form-control pms-settings selectize-pms-ip" id="pms_ip_selectize">
<option value="${config['pms_ip']}:${config['pms_port']}"
data-identifier="${config['pms_identifier']}"
data-ip="${config['pms_ip']}"
data-port="${config['pms_port']}"
data-local="${int(not int(config['pms_is_remote']))}"
data-ssl="${config['pms_ssl']}"
data-is_cloud="${config['pms_is_cloud']}"
data-label="${config['pms_name'] or 'Local'}"
selected>${config['pms_ip']}</option>
</select>
</div>
</div>
@@ -120,6 +128,7 @@
</div>
</div>
<input type="hidden" id="pms_valid" data-validate="validatePMSip" value="">
<input type="hidden" id="pms_ip" name="pms_ip" value="${config['pms_ip']}">
<input type="hidden" id="pms_is_cloud" name="pms_is_cloud" value="${config['pms_is_cloud']}">
<input type="hidden" id="pms_identifier" name="pms_identifier" value="${config['pms_identifier']}">
<a class="btn btn-dark" id="verify-plex-server" href="#" role="button">Verify</a>
@@ -314,7 +323,7 @@ $(document).ready(function() {
}
});
var $select_pms = $('#pms_ip').selectize({
var $select_pms = $('#pms_ip_selectize').selectize({
createOnBlur: true,
openOnFocus: true,
maxItems: 1,
@@ -324,13 +333,19 @@ $(document).ready(function() {
inputClass: 'form-control selectize-input',
render: {
item: function (item, escape) {
if (!item.label) {
$.extend(item,
$(this.revertSettings.$children)
.filter('[value=' + item.value + ']').data()
);
}
var label = item.label || item.value;
var caption = item.label ? item.value : null;
return '<div data-ssl="' + item.httpsRequired +
'" data-local="' + item.local +
'" data-identifier="' + item.clientIdentifier +
var caption = item.label ? item.ip : null;
return '<div data-identifier="' + item.clientIdentifier +
'" data-ip="' + item.ip +
'" data-port="' + item.port +
'" data-local="' + item.local +
'" data-ssl="' + item.httpsRequired +
'" data-is_cloud="' + item.is_cloud +
'" data-label="' + item.label + '">' +
'<span class="item-text">' + escape(label) + '</span>' +
@@ -340,11 +355,11 @@ $(document).ready(function() {
option: function (item, escape) {
var label = item.label || item.value;
var caption = item.label ? item.value : null;
return '<div data-ssl="' + item.httpsRequired +
'" data-local="' + item.local +
'" data-identifier="' + item.clientIdentifier +
return '<div data-identifier="' + item.clientIdentifier +
'" data-ip="' + item.ip +
'" data-port="' + item.port +
'" data-local="' + item.local +
'" data-ssl="' + item.httpsRequired +
'" data-is_cloud="' + item.is_cloud +
'" data-label="' + item.label + '">' +
escape(label) +
@@ -355,18 +370,27 @@ $(document).ready(function() {
create: function(input) {
return {label: '', value: input};
},
onInitialize: function () {
var s = this;
this.revertSettings.$children.each(function () {
$.extend(s.options[this.value], $(this).data());
});
},
onChange: function (item) {
var pms_ip_selected = this.getItem(item)[0];
var identifier = $(pms_ip_selected).data('identifier');
var ip = $(pms_ip_selected).data('ip');
var port = $(pms_ip_selected).data('port');
var local = $(pms_ip_selected).data('local');
var ssl = $(pms_ip_selected).data('ssl');
var is_cloud = $(pms_ip_selected).data('is_cloud');
var value = $(pms_ip_selected).data('value');
$("#pms_valid").val(identifier !== 'undefined' ? 'valid' : '');
$("#pms-verify-status").html(identifier !== 'undefined' ? '<i class="fa fa-check"></i>&nbsp; Server found!' : '').fadeIn('fast');
$("#pms_identifier").val(identifier !== 'undefined' ? identifier : '');
$('#pms_ip').val(ip !== 'undefined' ? ip : value);
$('#pms_port').val(port !== 'undefined' ? port : 32400);
$('#pms_is_remote_checkbox').prop('checked', (local !== 'undefined' && local === 0));
$('#pms_is_remote').val(local !== 'undefined' && local === 0 ? 1 : 0);
@@ -399,9 +423,10 @@ $(document).ready(function() {
},
success: function (result) {
if (result) {
var existing_value = $('#pms_ip').val();
var existing_ip = $('#pms_ip').val();
var existing_port = $('#pms_port').val();
result.forEach(function (item) {
if (item.value === existing_value) {
if (item.ip === existing_ip && item.port === existing_port) {
select_pms.updateOption(item.value, item);
} else {
select_pms.addOption(item);

View File

@@ -691,7 +691,7 @@
<img src="${(base_url_image + 'images/libraries/show.png') if base_url_image else 'https://tautulli.com/images/libraries/show.png'}" class="sub-header-icon" width="30" height="30" style="border: none;-ms-interpolation-mode: bicubic;max-width: 100%;height: 30px;width: 30px;vertical-align: middle;margin-right: 5px;margin-bottom: 5px;"> Recently Added TV Shows
</div>
<div class="sub-header-count" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;">
<span class="count" style="color: #E5A00D;">${len(recently_added['show'])}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">show${'s' if len(recently_added['show']) > 1 else ''}</span> /
<span class="count" style="color: #E5A00D;">${len(recently_added['show'])}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">show${'s' if len(recently_added['show']) > 1 else ''}</span>&nbsp;/&nbsp;
<% total_episodes = sum(season['episode_count'] for show in recently_added['show'] for season in show['season']) %>
<span class="count" style="color: #E5A00D;">${total_episodes}</span> <span class="count-units" style="color: #aaaaaa;font-size: 20px;text-transform: uppercase;">episode${'s' if total > 1 else ''}</span>
</div>
@@ -744,7 +744,7 @@
<td class="card-info-body" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-size: 0.75rem;vertical-align: top;padding: 5px;height: 100%;">
<p class="nowrap mb5" style="font-family: 'Open Sans', Helvetica, Arial, sans-serif;font-weight: 400;margin: 0;margin-bottom: 5px;white-space: nowrap;text-overflow: ellipsis;overflow: hidden;max-width: 325px;color: #ffffff;">
% if show['season_count'] > 1:
<em>${show['season_count']} seasons /</em>
<em>${show['season_count']} seasons&nbsp;/&nbsp;</em>
% endif
<% total_show_episodes = sum(s['episode_count'] for s in show['season']) %>
<em>${total_show_episodes} episode${'s' if total_show_episodes > 1 else ''}</em>

View File

@@ -692,7 +692,7 @@
<img src="${(base_url_image + 'images/libraries/show.png') if base_url_image else 'https://tautulli.com/images/libraries/show.png'}" class="sub-header-icon" width="30" height="30"/> Recently Added TV Shows
</div>
<div class="sub-header-count">
<span class="count">${len(recently_added['show'])}</span> <span class="count-units">show${'s' if len(recently_added['show']) > 1 else ''}</span> /
<span class="count">${len(recently_added['show'])}</span> <span class="count-units">show${'s' if len(recently_added['show']) > 1 else ''}</span>&nbsp;/&nbsp;
<% total_episodes = sum(season['episode_count'] for show in recently_added['show'] for season in show['season']) %>
<span class="count">${total_episodes}</span> <span class="count-units">episode${'s' if total > 1 else ''}</span>
</div>
@@ -745,7 +745,7 @@
<td class="card-info-body">
<p class="nowrap mb5">
% if show['season_count'] > 1:
<em>${show['season_count']} seasons /</em>
<em>${show['season_count']} seasons&nbsp;/&nbsp;</em>
% endif
<% total_show_episodes = sum(s['episode_count'] for s in show['season']) %>
<em>${total_show_episodes} episode${'s' if total_show_episodes > 1 else ''}</em>

View File

@@ -4,47 +4,50 @@
#
# INSTALLATION NOTES
#
# 1. Rename this file as you want, ensuring that it ends in .service
# e.g. 'tautulli.service'
# 1. Copy this file into your systemd service unit directory (often '/lib/systemd/system')
# and name it 'tautulli.service' with the following command:
# cp /opt/Tautulli/init-scripts/init.systemd /lib/systemd/system/tautulli.service
#
# 2. Adjust configuration settings as required. More details in the
# "CONFIGURATION NOTES" section shown below.
# 2. Edit the new tautulli.service file with configuration settings as required.
# More details in the "CONFIGURATION NOTES" section shown below.
#
# 3. Copy this file into your systemd service unit directory, which is
# often '/lib/systemd/system'.
#
# 4. Enable boot-time autostart with the following commands:
# 3. Enable boot-time autostart with the following commands:
# systemctl daemon-reload
# systemctl enable tautulli.service
#
# 5. Start now with the following command:
# 4. Start now with the following command:
# systemctl start tautulli.service
#
# CONFIGURATION NOTES
#
# - The example settings in this file assume that you will run Tautulli as user: tautulli
# - To create this user and give it ownership of the tautulli directory:
# - The example settings in this file assume that Tautulli is installed to: /opt/Tautulli
#
# - To create this user and give it ownership of the Tautulli directory:
# sudo adduser --system --no-create-home tautulli
# sudo chown tautulli:nogroup -R /opt/Tautulli
#
# - Option names (e.g. ExecStart=, Type=) appear to be case-sensitive)
#
# - Adjust ExecStart= to point to:
# 1. Your Tautulli executable,
# 1. Your Tautulli executable
# - Default: /opt/Tautulli/Tautulli.py
# 2. Your config file (recommended is to put it somewhere in /etc)
# - Default: --config /opt/Tautulli/config.ini
# 3. Your datadir (recommended is to NOT put it in your Tautulli exec dir)
# - Default: --datadir /opt/Tautulli
#
# - Adjust User= and Group= to the user/group you want Tautulli to run as.
#
# - WantedBy= specifies which target (i.e. runlevel) to start Tautulli for.
# multi-user.target equates to runlevel 3 (multi-user text mode)
# graphical.target equates to runlevel 5 (multi-user X11 graphical mode)
# multi-user.target equates to runlevel 3 (multi-user text mode)
# graphical.target equates to runlevel 5 (multi-user X11 graphical mode)
[Unit]
Description=Tautulli - Stats for Plex Media Server usage
Wants=network-online.target
After=network-online.target
[Service]
ExecStart=/opt/Tautulli/Tautulli.py --quiet --daemon --nolaunch --config /opt/Tautulli/config.ini --datadir /opt/Tautulli
ExecStart=/opt/Tautulli/Tautulli.py --config /opt/Tautulli/config.ini --datadir /opt/Tautulli --quiet --daemon --nolaunch
GuessMainPID=no
Type=forking
User=tautulli

View File

@@ -1,127 +0,0 @@
# Copyright (c) 2009 Raymond Hettinger
#
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
# OTHER DEALINGS IN THE SOFTWARE.
from UserDict import DictMixin
class OrderedDict(dict, DictMixin):
def __init__(self, *args, **kwds):
if len(args) > 1:
raise TypeError('expected at most 1 arguments, got %d' % len(args))
try:
self.__end
except AttributeError:
self.clear()
self.update(*args, **kwds)
def clear(self):
self.__end = end = []
end += [None, end, end] # sentinel node for doubly linked list
self.__map = {} # key --> [key, prev, next]
dict.clear(self)
def __setitem__(self, key, value):
if key not in self:
end = self.__end
curr = end[1]
curr[2] = end[1] = self.__map[key] = [key, curr, end]
dict.__setitem__(self, key, value)
def __delitem__(self, key):
dict.__delitem__(self, key)
key, prev, next = self.__map.pop(key)
prev[2] = next
next[1] = prev
def __iter__(self):
end = self.__end
curr = end[2]
while curr is not end:
yield curr[0]
curr = curr[2]
def __reversed__(self):
end = self.__end
curr = end[1]
while curr is not end:
yield curr[0]
curr = curr[1]
def popitem(self, last=True):
if not self:
raise KeyError('dictionary is empty')
if last:
key = reversed(self).next()
else:
key = iter(self).next()
value = self.pop(key)
return key, value
def __reduce__(self):
items = [[k, self[k]] for k in self]
tmp = self.__map, self.__end
del self.__map, self.__end
inst_dict = vars(self).copy()
self.__map, self.__end = tmp
if inst_dict:
return (self.__class__, (items,), inst_dict)
return self.__class__, (items,)
def keys(self):
return list(self)
setdefault = DictMixin.setdefault
update = DictMixin.update
pop = DictMixin.pop
values = DictMixin.values
items = DictMixin.items
iterkeys = DictMixin.iterkeys
itervalues = DictMixin.itervalues
iteritems = DictMixin.iteritems
def __repr__(self):
if not self:
return '%s()' % (self.__class__.__name__,)
return '%s(%r)' % (self.__class__.__name__, self.items())
def copy(self):
return self.__class__(self)
@classmethod
def fromkeys(cls, iterable, value=None):
d = cls()
for key in iterable:
d[key] = value
return d
def __eq__(self, other):
if isinstance(other, OrderedDict):
if len(self) != len(other):
return False
for p, q in zip(self.items(), other.items()):
if p != q:
return False
return True
return dict.__eq__(self, other)
def __ne__(self, other):
return not self == other

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,2 @@
__import__("pkg_resources").declare_namespace(__name__)
from .traybar import SysTrayIcon

314
lib/systray/traybar.py Normal file
View File

@@ -0,0 +1,314 @@
import os
from .win32_adapter import *
import threading
import uuid
class SysTrayIcon(object):
"""
menu_options: tuple of tuples (menu text, menu icon path or None, function name)
menu text and tray hover text should be Unicode
hover_text length is limited to 128; longer text will be truncated
Can be used as context manager to enable automatic termination of tray
if parent thread is closed:
with SysTrayIcon(icon, hover_text) as systray:
for item in ['item1', 'item2', 'item3']:
systray.update(hover_text=item)
do_something(item)
"""
QUIT = 'QUIT'
SPECIAL_ACTIONS = [QUIT]
FIRST_ID = 1023
def __init__(self,
icon,
hover_text,
menu_options=None,
on_quit=None,
default_menu_index=None,
window_class_name=None):
self._icon = icon
self._icon_shared = False
self._hover_text = hover_text
self._on_quit = on_quit
menu_options = menu_options or ()
menu_options = menu_options + (('Quit', None, SysTrayIcon.QUIT, None),)
self._next_action_id = SysTrayIcon.FIRST_ID
self._menu_actions_by_id = set()
self._menu_options = self._add_ids_to_menu_options(list(menu_options))
self._menu_actions_by_id = dict(self._menu_actions_by_id)
window_class_name = window_class_name or ("SysTrayIconPy-%s" % (str(uuid.uuid4())))
self._default_menu_index = (default_menu_index or 0)
self._window_class_name = encode_for_locale(window_class_name)
self._message_dict = {RegisterWindowMessage("TaskbarCreated"): self._restart,
WM_DESTROY: self._destroy,
WM_CLOSE: self._destroy,
WM_COMMAND: self._command,
WM_USER+20: self._notify}
self._notify_id = None
self._message_loop_thread = None
self._hwnd = None
self._hicon = 0
self._hinst = None
self._window_class = None
self._menu = None
self._register_class()
def __enter__(self):
"""Context manager so SysTray can automatically close"""
self.start()
return self
def __exit__(self, *args):
"""Context manager so SysTray can automatically close"""
self.shutdown()
def WndProc(self, hwnd, msg, wparam, lparam):
hwnd = HANDLE(hwnd)
wparam = WPARAM(wparam)
lparam = LPARAM(lparam)
if msg in self._message_dict:
self._message_dict[msg](hwnd, msg, wparam.value, lparam.value)
return DefWindowProc(hwnd, msg, wparam, lparam)
def _register_class(self):
# Register the Window class.
self._window_class = WNDCLASS()
self._hinst = self._window_class.hInstance = GetModuleHandle(None)
self._window_class.lpszClassName = self._window_class_name
self._window_class.style = CS_VREDRAW | CS_HREDRAW
self._window_class.hCursor = LoadCursor(0, IDC_ARROW)
self._window_class.hbrBackground = COLOR_WINDOW
self._window_class.lpfnWndProc = LPFN_WNDPROC(self.WndProc)
RegisterClass(ctypes.byref(self._window_class))
def _create_window(self):
style = WS_OVERLAPPED | WS_SYSMENU
self._hwnd = CreateWindowEx(0, self._window_class_name,
self._window_class_name,
style,
0,
0,
CW_USEDEFAULT,
CW_USEDEFAULT,
0,
0,
self._hinst,
None)
UpdateWindow(self._hwnd)
self._refresh_icon()
def _message_loop_func(self):
self._create_window()
PumpMessages()
def start(self):
if self._hwnd:
return # already started
self._message_loop_thread = threading.Thread(target=self._message_loop_func)
self._message_loop_thread.start()
def shutdown(self):
if not self._hwnd:
return # not started
PostMessage(self._hwnd, WM_CLOSE, 0, 0)
self._message_loop_thread.join()
def update(self, icon=None, hover_text=None):
""" update icon image and/or hover text """
if icon:
self._icon = icon
self._load_icon()
if hover_text:
self._hover_text = hover_text
self._refresh_icon()
def _add_ids_to_menu_options(self, menu_options):
result = []
for menu_option in menu_options:
option_text, option_icon, option_action, option_state = menu_option
if callable(option_action) or option_action in SysTrayIcon.SPECIAL_ACTIONS:
self._menu_actions_by_id.add((self._next_action_id, option_action))
result.append(menu_option + (self._next_action_id,))
elif option_action == 'separator':
result.append((option_text,
option_icon,
option_action,
option_state,
self._next_action_id))
elif non_string_iterable(option_action):
result.append((option_text,
option_icon,
option_state,
self._add_ids_to_menu_options(option_action),
self._next_action_id))
else:
raise Exception('Unknown item', option_text, option_icon, option_action)
self._next_action_id += 1
return result
def _load_icon(self):
# release previous icon, if a custom one was loaded
# note: it's important *not* to release the icon if we loaded the default system icon (with
# the LoadIcon function) - this is why we assign self._hicon only if it was loaded using LoadImage
if not self._icon_shared and self._hicon != 0:
DestroyIcon(self._hicon)
self._hicon = 0
# Try and find a custom icon
hicon = 0
if self._icon is not None and os.path.isfile(self._icon):
icon_flags = LR_LOADFROMFILE | LR_DEFAULTSIZE
icon = encode_for_locale(self._icon)
hicon = self._hicon = LoadImage(0, icon, IMAGE_ICON, 0, 0, icon_flags)
self._icon_shared = False
# Can't find icon file - using default shared icon
if hicon == 0:
self._hicon = LoadIcon(0, IDI_APPLICATION)
self._icon_shared = True
self._icon = None
def _refresh_icon(self):
if self._hwnd is None:
return
if self._hicon == 0:
self._load_icon()
if self._notify_id:
message = NIM_MODIFY
else:
message = NIM_ADD
self._notify_id = NotifyData(self._hwnd,
0,
NIF_ICON | NIF_MESSAGE | NIF_TIP,
WM_USER+20,
self._hicon,
self._hover_text)
Shell_NotifyIcon(message, ctypes.byref(self._notify_id))
def _restart(self, hwnd, msg, wparam, lparam):
self._refresh_icon()
def _destroy(self, hwnd, msg, wparam, lparam):
if self._on_quit:
self._on_quit(self)
nid = NotifyData(self._hwnd, 0)
Shell_NotifyIcon(NIM_DELETE, ctypes.byref(nid))
PostQuitMessage(0) # Terminate the app.
# TODO * release self._menu with DestroyMenu and reset the memeber
# * release self._hicon with DestoryIcon and reset the member
# * release loaded menu icons (loaded in _load_menu_icon) with DeleteObject
# (we don't keep those objects anywhere now)
self._hwnd = None
self._notify_id = None
def _notify(self, hwnd, msg, wparam, lparam):
if lparam == WM_LBUTTONDBLCLK:
self._execute_menu_option(self._default_menu_index + SysTrayIcon.FIRST_ID)
elif lparam == WM_RBUTTONUP:
self._show_menu()
elif lparam == WM_LBUTTONUP:
pass
return True
def _show_menu(self):
if self._menu is None:
self._menu = CreatePopupMenu()
self._create_menu(self._menu, self._menu_options)
#SetMenuDefaultItem(self._menu, 1000, 0)
pos = POINT()
GetCursorPos(ctypes.byref(pos))
# See http://msdn.microsoft.com/library/default.asp?url=/library/en-us/winui/menus_0hdi.asp
SetForegroundWindow(self._hwnd)
TrackPopupMenu(self._menu,
TPM_LEFTALIGN,
pos.x,
pos.y,
0,
self._hwnd,
None)
PostMessage(self._hwnd, WM_NULL, 0, 0)
def _create_menu(self, menu, menu_options):
for option_text, option_icon, option_action, option_state, option_id in menu_options[::-1]:
if option_icon:
option_icon = self._prep_menu_icon(option_icon)
mi_fstate = 0
mi_ftype = 0
if option_state == 'default':
mi_fstate = mi_fstate | MFS_DEFAULT
if option_state == 'highlight':
mi_fstate = mi_fstate | MFS_HILITE
if option_state == 'disabled':
mi_fstate = mi_fstate | MFS_DISABLED
if option_action == 'separator':
mi_ftype = mi_ftype | MFT_SEPARATOR
if isinstance(option_action, tuple):
submenu = CreatePopupMenu()
self._create_menu(submenu, option_action)
item = PackMENUITEMINFO(text=option_text,
hbmpItem=option_icon,
hSubMenu=submenu)
InsertMenuItem(menu, 0, 1, ctypes.byref(item))
else:
item = PackMENUITEMINFO(text=option_text,
hbmpItem=option_icon,
wID=option_id,
fState=mi_fstate,
fType=mi_ftype)
InsertMenuItem(menu, 0, 1, ctypes.byref(item))
def _prep_menu_icon(self, icon):
icon = encode_for_locale(icon)
# First load the icon.
ico_x = GetSystemMetrics(SM_CXSMICON)
ico_y = GetSystemMetrics(SM_CYSMICON)
hicon = LoadImage(0, icon, IMAGE_ICON, ico_x, ico_y, LR_LOADFROMFILE)
hdcBitmap = CreateCompatibleDC(None)
hdcScreen = GetDC(None)
hbm = CreateCompatibleBitmap(hdcScreen, ico_x, ico_y)
hbmOld = SelectObject(hdcBitmap, hbm)
# Fill the background.
brush = GetSysColorBrush(COLOR_MENU)
FillRect(hdcBitmap, ctypes.byref(RECT(0, 0, 16, 16)), brush)
# draw the icon
DrawIconEx(hdcBitmap, 0, 0, hicon, ico_x, ico_y, 0, 0, DI_NORMAL)
SelectObject(hdcBitmap, hbmOld)
# No need to free the brush
DeleteDC(hdcBitmap)
DestroyIcon(hicon)
return hbm
def _command(self, hwnd, msg, wparam, lparam):
id = LOWORD(wparam)
self._execute_menu_option(id)
def _execute_menu_option(self, id):
menu_action = self._menu_actions_by_id[id]
if menu_action == SysTrayIcon.QUIT:
DestroyWindow(self._hwnd)
else:
menu_action(self)
def non_string_iterable(obj):
try:
iter(obj)
except TypeError:
return False
else:
return not isinstance(obj, str)

View File

@@ -0,0 +1,199 @@
import ctypes
import ctypes.wintypes
import locale
import sys
RegisterWindowMessage = ctypes.windll.user32.RegisterWindowMessageA
LoadCursor = ctypes.windll.user32.LoadCursorA
LoadIcon = ctypes.windll.user32.LoadIconA
LoadImage = ctypes.windll.user32.LoadImageA
RegisterClass = ctypes.windll.user32.RegisterClassA
CreateWindowEx = ctypes.windll.user32.CreateWindowExA
UpdateWindow = ctypes.windll.user32.UpdateWindow
DefWindowProc = ctypes.windll.user32.DefWindowProcA
GetSystemMetrics = ctypes.windll.user32.GetSystemMetrics
InsertMenuItem = ctypes.windll.user32.InsertMenuItemA
PostMessage = ctypes.windll.user32.PostMessageA
PostQuitMessage = ctypes.windll.user32.PostQuitMessage
SetMenuDefaultItem = ctypes.windll.user32.SetMenuDefaultItem
GetCursorPos = ctypes.windll.user32.GetCursorPos
SetForegroundWindow = ctypes.windll.user32.SetForegroundWindow
TrackPopupMenu = ctypes.windll.user32.TrackPopupMenu
CreatePopupMenu = ctypes.windll.user32.CreatePopupMenu
CreateCompatibleDC = ctypes.windll.gdi32.CreateCompatibleDC
GetDC = ctypes.windll.user32.GetDC
CreateCompatibleBitmap = ctypes.windll.gdi32.CreateCompatibleBitmap
GetSysColorBrush = ctypes.windll.user32.GetSysColorBrush
FillRect = ctypes.windll.user32.FillRect
DrawIconEx = ctypes.windll.user32.DrawIconEx
SelectObject = ctypes.windll.gdi32.SelectObject
DeleteDC = ctypes.windll.gdi32.DeleteDC
DestroyWindow = ctypes.windll.user32.DestroyWindow
GetModuleHandle = ctypes.windll.kernel32.GetModuleHandleA
GetMessage = ctypes.windll.user32.GetMessageA
TranslateMessage = ctypes.windll.user32.TranslateMessage
DispatchMessage = ctypes.windll.user32.DispatchMessageA
Shell_NotifyIcon = ctypes.windll.shell32.Shell_NotifyIcon
DestroyIcon = ctypes.windll.user32.DestroyIcon
NIM_ADD = 0
NIM_MODIFY = 1
NIM_DELETE = 2
NIF_ICON = 2
NIF_MESSAGE = 1
NIF_TIP = 4
MIIM_STATE = 1
MIIM_ID = 2
MIIM_SUBMENU = 4
MIIM_STRING = 64
MIIM_BITMAP = 128
MIIM_FTYPE = 256
WM_DESTROY = 2
WM_CLOSE = 16
WM_COMMAND = 273
WM_USER = 1024
WM_LBUTTONDBLCLK = 515
WM_RBUTTONUP = 517
WM_LBUTTONUP = 514
WM_NULL = 0
CS_VREDRAW = 1
CS_HREDRAW = 2
IDC_ARROW = 32512
COLOR_WINDOW = 5
WS_OVERLAPPED = 0
WS_SYSMENU = 524288
CW_USEDEFAULT = -2147483648
LR_LOADFROMFILE = 16
LR_DEFAULTSIZE = 64
IMAGE_ICON = 1
IDI_APPLICATION = 32512
TPM_LEFTALIGN = 0
SM_CXSMICON = 49
SM_CYSMICON = 50
COLOR_MENU = 4
DI_NORMAL = 3
MFS_DISABLED = 3
MFS_DEFAULT = 4096
MFS_HILITE = 128
MFT_SEPARATOR = 2048
WPARAM = ctypes.wintypes.WPARAM
LPARAM = ctypes.wintypes.LPARAM
HANDLE = ctypes.wintypes.HANDLE
if ctypes.sizeof(ctypes.c_long) == ctypes.sizeof(ctypes.c_void_p):
LRESULT = ctypes.c_long
elif ctypes.sizeof(ctypes.c_longlong) == ctypes.sizeof(ctypes.c_void_p):
LRESULT = ctypes.c_longlong
SZTIP_MAX_LENGTH = 128
LOCALE_ENCODING = locale.getpreferredencoding()
def encode_for_locale(s):
"""
Encode text items for system locale. If encoding fails, fall back to ASCII.
"""
try:
return s.encode(LOCALE_ENCODING, 'ignore')
except (AttributeError, UnicodeDecodeError):
return s.decode('ascii', 'ignore').encode(LOCALE_ENCODING)
POINT = ctypes.wintypes.POINT
RECT = ctypes.wintypes.RECT
MSG = ctypes.wintypes.MSG
LPFN_WNDPROC = ctypes.CFUNCTYPE(LRESULT, HANDLE, ctypes.c_uint, WPARAM, LPARAM)
class WNDCLASS(ctypes.Structure):
_fields_ = [("style", ctypes.c_uint),
("lpfnWndProc", LPFN_WNDPROC),
("cbClsExtra", ctypes.c_int),
("cbWndExtra", ctypes.c_int),
("hInstance", HANDLE),
("hIcon", HANDLE),
("hCursor", HANDLE),
("hbrBackground", HANDLE),
("lpszMenuName", ctypes.c_char_p),
("lpszClassName", ctypes.c_char_p),
]
class MENUITEMINFO(ctypes.Structure):
_fields_ = [("cbSize", ctypes.c_uint),
("fMask", ctypes.c_uint),
("fType", ctypes.c_uint),
("fState", ctypes.c_uint),
("wID", ctypes.c_uint),
("hSubMenu", HANDLE),
("hbmpChecked", HANDLE),
("hbmpUnchecked", HANDLE),
("dwItemData", ctypes.c_void_p),
("dwTypeData", ctypes.c_char_p),
("cch", ctypes.c_uint),
("hbmpItem", HANDLE),
]
class NOTIFYICONDATA(ctypes.Structure):
_fields_ = [("cbSize", ctypes.c_uint),
("hWnd", HANDLE),
("uID", ctypes.c_uint),
("uFlags", ctypes.c_uint),
("uCallbackMessage", ctypes.c_uint),
("hIcon", HANDLE),
("szTip", ctypes.c_char * SZTIP_MAX_LENGTH),
("dwState", ctypes.c_uint),
("dwStateMask", ctypes.c_uint),
("szInfo", ctypes.c_char * 256),
("uTimeout", ctypes.c_uint),
("szInfoTitle", ctypes.c_char * 64),
("dwInfoFlags", ctypes.c_uint),
("guidItem", ctypes.c_char * 16),
]
if sys.getwindowsversion().major >= 5:
_fields_.append(("hBalloonIcon", HANDLE))
def PackMENUITEMINFO(text=None, hbmpItem=None, wID=None, hSubMenu=None,
fType=None, fState=None):
res = MENUITEMINFO()
res.cbSize = ctypes.sizeof(res)
res.fMask = 0
if hbmpItem is not None:
res.fMask |= MIIM_BITMAP
res.hbmpItem = hbmpItem
if wID is not None:
res.fMask |= MIIM_ID
res.wID = wID
if text is not None:
text = encode_for_locale(text)
res.fMask |= MIIM_STRING
res.dwTypeData = text
if hSubMenu is not None:
res.fMask |= MIIM_SUBMENU
res.hSubMenu = hSubMenu
if fType is not None:
res.fMask |= MIIM_FTYPE
res.fType = fType
if fState is not None:
res.fMask |= MIIM_STATE
res.fState = fState
return res
def LOWORD(w):
return w & 0xFFFF
def PumpMessages():
msg = MSG()
while GetMessage(ctypes.byref(msg), None, 0, 0) > 0:
TranslateMessage(ctypes.byref(msg))
DispatchMessage(ctypes.byref(msg))
def NotifyData(hWnd=0, uID=0, uFlags=0, uCallbackMessage=0, hIcon=0, szTip=""):
szTip = encode_for_locale(szTip)[:SZTIP_MAX_LENGTH]
res = NOTIFYICONDATA()
res.cbSize = ctypes.sizeof(res)
res.hWnd = hWnd
res.uID = uID
res.uFlags = uFlags
res.uCallbackMessage = uCallbackMessage
res.hIcon = hIcon
res.szTip = szTip
return res

View File

@@ -92,9 +92,11 @@ LATEST_VERSION = None
COMMITS_BEHIND = None
PREV_RELEASE = None
LATEST_RELEASE = None
UPDATE_AVAILABLE = False
UMASK = None
HTTP_PORT = None
HTTP_ROOT = None
DEV = False
@@ -105,6 +107,8 @@ PLEX_SERVER_UP = None
TRACKER = None
WIN_SYS_TRAY_ICON = None
def initialize(config_file):
with INIT_LOCK:
@@ -256,7 +260,7 @@ def initialize(config_file):
# Check for new versions
if CONFIG.CHECK_GITHUB_ON_STARTUP and CONFIG.CHECK_GITHUB:
try:
LATEST_VERSION = versioncheck.check_github()
LATEST_VERSION = versioncheck.check_update()
except:
logger.exception(u"Unhandled exception")
LATEST_VERSION = CURRENT_VERSION
@@ -378,6 +382,51 @@ def launch_browser(host, port, root):
logger.error(u"Could not launch browser: %s" % e)
def win_system_tray():
from systray import SysTrayIcon
def tray_open(sysTrayIcon):
launch_browser(plexpy.CONFIG.HTTP_HOST, plexpy.HTTP_PORT, plexpy.HTTP_ROOT)
def tray_check_update(sysTrayIcon):
versioncheck.check_update()
def tray_update(sysTrayIcon):
if plexpy.UPDATE_AVAILABLE:
plexpy.SIGNAL = 'update'
else:
hover_text = common.PRODUCT + ' - No Update Available'
plexpy.WIN_SYS_TRAY_ICON.update(hover_text=hover_text)
def tray_restart(sysTrayIcon):
plexpy.SIGNAL = 'restart'
def tray_quit(sysTrayIcon):
plexpy.SIGNAL = 'shutdown'
if plexpy.UPDATE_AVAILABLE:
icon = os.path.join(plexpy.PROG_DIR, 'data/interfaces/', plexpy.CONFIG.INTERFACE, 'images/logo_tray-update.ico')
hover_text = common.PRODUCT + ' - Update Available!'
else:
icon = os.path.join(plexpy.PROG_DIR, 'data/interfaces/', plexpy.CONFIG.INTERFACE, 'images/logo_tray.ico')
hover_text = common.PRODUCT
menu_options = (('Open Tautulli', None, tray_open, 'default'),
('', None, 'separator', None),
('Check for Updates', None, tray_check_update, None),
('Update', None, tray_update, None),
('Restart', None, tray_restart, None))
logger.info(u"Launching system tray icon.")
try:
plexpy.WIN_SYS_TRAY_ICON = SysTrayIcon(icon, hover_text, menu_options, on_quit=tray_quit)
plexpy.WIN_SYS_TRAY_ICON.start()
except Exception as e:
logger.error(u"Unable to launch system tray icon: %s." % e)
plexpy.WIN_SYS_TRAY_ICON = None
def initialize_scheduler():
"""
Start the scheduled background tasks. Re-schedule if interval settings changed.
@@ -391,7 +440,7 @@ def initialize_scheduler():
# Update check
github_minutes = CONFIG.CHECK_GITHUB_INTERVAL if CONFIG.CHECK_GITHUB_INTERVAL and CONFIG.CHECK_GITHUB else 0
schedule_job(versioncheck.check_github, 'Check GitHub for updates',
schedule_job(versioncheck.check_update, 'Check GitHub for updates',
hours=0, minutes=github_minutes, seconds=0, args=(bool(CONFIG.PLEXPY_AUTO_UPDATE), True))
backup_hours = CONFIG.BACKUP_INTERVAL if 1 <= CONFIG.BACKUP_INTERVAL <= 24 else 6
@@ -422,7 +471,7 @@ def initialize_scheduler():
schedule_job(activity_pinger.connect_server, 'Check for server response',
hours=0, minutes=0, seconds=0)
schedule_job(web_socket.send_ping, 'Websocket ping',
hours=0, minutes=0, seconds=10)
hours=0, minutes=0, seconds=10 * bool(CONFIG.WEBSOCKET_MONITOR_PING_PONG))
else:
# Cancel all jobs
@@ -668,7 +717,8 @@ def dbcheck():
'CREATE TABLE IF NOT EXISTS newsletter_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, '
'newsletter_id INTEGER, agent_id INTEGER, agent_name TEXT, notify_action TEXT, '
'subject_text TEXT, body_text TEXT, message_text TEXT, start_date TEXT, end_date TEXT, '
'start_time INTEGER, end_time INTEGER, uuid TEXT UNIQUE, filename TEXT, success INTEGER DEFAULT 0)'
'start_time INTEGER, end_time INTEGER, uuid TEXT UNIQUE, filename TEXT, email_msg_id TEXT, '
'success INTEGER DEFAULT 0)'
)
# recently_added table :: This table keeps record of recently added items
@@ -1563,6 +1613,15 @@ def dbcheck():
'ALTER TABLE newsletter_log ADD COLUMN filename TEXT'
)
# Upgrade newsletter_log table from earlier versions
try:
c_db.execute('SELECT email_msg_id FROM newsletter_log')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table newsletter_log.")
c_db.execute(
'ALTER TABLE newsletter_log ADD COLUMN email_msg_id TEXT'
)
# Upgrade newsletters table from earlier versions
try:
c_db.execute('SELECT id_name FROM newsletters')
@@ -1788,6 +1847,7 @@ def upgrade():
def shutdown(restart=False, update=False, checkout=False):
logger.info(u"Stopping Tautulli web server...")
cherrypy.engine.exit()
# Shutdown the websocket connection
@@ -1826,6 +1886,9 @@ def shutdown(restart=False, update=False, checkout=False):
logger.info(u"Removing pidfile %s", PIDFILE)
os.remove(PIDFILE)
if WIN_SYS_TRAY_ICON:
WIN_SYS_TRAY_ICON.shutdown()
if restart:
logger.info(u"Tautulli is restarting...")

View File

@@ -122,7 +122,7 @@ class API2:
else:
self._api_msg = 'Invalid apikey'
if self._api_authenticated and self._api_cmd in self._api_valid_methods:
self._api_msg = None
self._api_kwargs = kwargs
@@ -311,8 +311,8 @@ class API2:
self.backup_db()
else:
# If the backup is less then 24 h old lets make a backup
if any([os.path.getctime(os.path.join(plexpy.CONFIG.BACKUP_DIR, file_)) < (time.time() - 86400)
and file_.endswith('.db') for file_ in os.listdir(plexpy.CONFIG.BACKUP_DIR)]):
if not any(os.path.getctime(os.path.join(plexpy.CONFIG.BACKUP_DIR, file_)) > (time.time() - 86400)
and file_.endswith('.db') for file_ in os.listdir(plexpy.CONFIG.BACKUP_DIR)):
self.backup_db()
db = database.MonitorDatabase()

View File

@@ -177,7 +177,8 @@ HW_ENCODERS = [
'videotoolbox',
'mediacodecndk',
'vaapi',
'nvenc'
'nvenc',
'x264'
]
EXTRA_TYPES = {

View File

@@ -607,6 +607,7 @@ _CONFIG_DEFINITIONS = {
'UPDATE_NOTIFIERS_DB': (int, 'General', 1),
'VERIFY_SSL_CERT': (bool_int, 'Advanced', 1),
'VIDEO_LOGGING_ENABLE': (int, 'Monitoring', 1),
'WEBSOCKET_MONITOR_PING_PONG': (int, 'Advanced', 0),
'WEBSOCKET_CONNECTION_ATTEMPTS': (int, 'Advanced', 5),
'WEBSOCKET_CONNECTION_TIMEOUT': (int, 'Advanced', 5),
'WEEK_START_MONDAY': (int, 'General', 0),
@@ -629,7 +630,8 @@ _CONFIG_DEFINITIONS = {
'XBMC_ON_CONCURRENT': (int, 'XBMC', 0),
'XBMC_ON_NEWDEVICE': (int, 'XBMC', 0),
'JWT_SECRET': (str, 'Advanced', ''),
'SYSTEM_ANALYTICS': (int, 'Advanced', 1)
'SYSTEM_ANALYTICS': (int, 'Advanced', 1),
'WIN_SYS_TRAY': (int, 'General', 1)
}
_BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID', '_HOOK']
@@ -802,6 +804,7 @@ class Config(object):
if self.VIDEO_LOGGING_ENABLE == 0:
self.MOVIE_LOGGING_ENABLE = 0
self.TV_LOGGING_ENABLE = 0
self.CONFIG_VERSION = 1
if self.CONFIG_VERSION == 1:
@@ -817,11 +820,12 @@ class Config(object):
if 'library_statistics' in home_library_cards:
home_library_cards.remove('library_statistics')
self.HOME_LIBRARY_CARDS = home_library_cards
self.CONFIG_VERSION = 2
if self.CONFIG_VERSION == 2:
def rep(s):
return s.replace('{progress}','{progress_duration}')
return s.replace('{progress}', '{progress_duration}')
self.NOTIFY_ON_START_SUBJECT_TEXT = rep(self.NOTIFY_ON_START_SUBJECT_TEXT)
self.NOTIFY_ON_START_BODY_TEXT = rep(self.NOTIFY_ON_START_BODY_TEXT)
@@ -836,10 +840,13 @@ class Config(object):
self.NOTIFY_ON_WATCHED_SUBJECT_TEXT = rep(self.NOTIFY_ON_WATCHED_SUBJECT_TEXT)
self.NOTIFY_ON_WATCHED_BODY_TEXT = rep(self.NOTIFY_ON_WATCHED_BODY_TEXT)
self.NOTIFY_SCRIPTS_ARGS_TEXT = rep(self.NOTIFY_SCRIPTS_ARGS_TEXT)
self.CONFIG_VERSION = 3
if self.CONFIG_VERSION == 3:
if self.HTTP_ROOT == '/': self.HTTP_ROOT = ''
if self.HTTP_ROOT == '/':
self.HTTP_ROOT = ''
self.CONFIG_VERSION = 4
if self.CONFIG_VERSION == 4:
@@ -851,20 +858,26 @@ class Config(object):
home_sections = self.HOME_SECTIONS
home_sections.remove('library_stats')
self.HOME_SECTIONS = home_sections
self.CONFIG_VERSION = 5
if self.CONFIG_VERSION == 5:
self.MONITOR_PMS_UPDATES = 0
self.CONFIG_VERSION = 6
if self.CONFIG_VERSION == 6:
if self.GIT_USER.lower() == 'drzoidberg33':
self.GIT_USER = 'JonnyWong16'
self.CONFIG_VERSION = 7
if self.CONFIG_VERSION == 7:
def rep(s):
return s.replace('<tv>','<episode>').replace('</tv>','</episode>').replace('<music>','<track>').replace('</music>','</track>')
return s.replace('<tv>', '<episode>') \
.replace('</tv>', '</episode>') \
.replace('<music>', '<track>') \
.replace('</music>', '</track>')
self.NOTIFY_ON_START_SUBJECT_TEXT = rep(self.NOTIFY_ON_START_SUBJECT_TEXT)
self.NOTIFY_ON_START_BODY_TEXT = rep(self.NOTIFY_ON_START_BODY_TEXT)
@@ -904,3 +917,7 @@ class Config(object):
self.GIT_REPO = 'Tautulli'
self.CONFIG_VERSION = 11
if self.CONFIG_VERSION == 11:
self.ANON_REDIRECT = self.ANON_REDIRECT.replace('http://www.nullrefer.com/?',
'https://www.nullrefer.com/?')

View File

@@ -1115,3 +1115,20 @@ def grouper(iterable, n, fillvalue=None):
# grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx
args = [iter(iterable)] * n
return izip_longest(fillvalue=fillvalue, *args)
def traverse_map(obj, func):
if isinstance(obj, list):
new_obj = []
for i in obj:
new_obj.append(traverse_map(i, func))
elif isinstance(obj, dict):
new_obj = {}
for k, v in obj.iteritems():
new_obj[traverse_map(k, func)] = traverse_map(v, func)
else:
new_obj = func(obj)
return new_obj

View File

@@ -33,7 +33,9 @@ class HTTPHandler(object):
Retrieve data from Plex Server
"""
def __init__(self, urls, headers=None, token=None, timeout=10, ssl_verify=True):
def __init__(self, urls, headers=None, token=None, timeout=10, ssl_verify=True, silent=False):
self._silent = silent
if isinstance(urls, basestring):
self.urls = urls.split() or urls.split(',')
else:
@@ -131,7 +133,8 @@ class HTTPHandler(object):
for work in pool.imap_unordered(part, urls, chunk):
yield work
except Exception as e:
logger.error(u"Failed to yield request: %s" % e)
if not self._silent:
logger.error(u"Failed to yield request: %s" % e)
finally:
pool.close()
pool.join()
@@ -141,13 +144,16 @@ class HTTPHandler(object):
try:
r = session.request(self.request_type, url, headers=self.headers, timeout=self.timeout)
except IOError as e:
logger.warn(u"Failed to access uri endpoint %s with error %s" % (self.uri, e))
if not self._silent:
logger.warn(u"Failed to access uri endpoint %s with error %s" % (self.uri, e))
return None
except Exception as e:
logger.warn(u"Failed to access uri endpoint %s. Is your server maybe accepting SSL connections only? %s" % (self.uri, e))
if not self._silent:
logger.warn(u"Failed to access uri endpoint %s. Is your server maybe accepting SSL connections only? %s" % (self.uri, e))
return None
except:
logger.warn(u"Failed to access uri endpoint %s with Uncaught exception." % self.uri)
if not self._silent:
logger.warn(u"Failed to access uri endpoint %s with Uncaught exception." % self.uri)
return None
response_status = r.status
@@ -157,7 +163,8 @@ class HTTPHandler(object):
if response_status in (200, 201):
return self._http_format_output(response_content, response_headers)
else:
logger.warn(u"Failed to access uri endpoint %s. Status code %r" % (self.uri, response_status))
if not self._silent:
logger.warn(u"Failed to access uri endpoint %s. Status code %r" % (self.uri, response_status))
return None
def _http_format_output(self, response_content, response_headers):
@@ -183,5 +190,6 @@ class HTTPHandler(object):
return output
except Exception as e:
logger.warn(u"Failed format response from uri %s to %s error %s" % (self.uri, self.output_format, e))
if not self._silent:
logger.warn(u"Failed format response from uri %s to %s error %s" % (self.uri, self.output_format, e))
return None

View File

@@ -633,7 +633,8 @@ class Libraries(object):
if 'media_info' in child_metadata and len(child_metadata['media_info']) > 0:
media_info = child_metadata['media_info'][0]
if 'parts' in media_info and len (media_info['parts']) > 0:
media_part_info = media_info['parts'][0]
media_part_info = next((p for p in media_info['parts'] if p['selected']),
media_info['parts'][0])
file_size += helpers.cast_to_int(media_part_info.get('file_size', 0))

View File

@@ -18,6 +18,7 @@ import time
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
import email.utils
import plexpy
import database
@@ -86,6 +87,9 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
body = newsletter_config['body']
message = newsletter_config['message']
email_msg_id = email.utils.make_msgid()
email_reply_msg_id = get_last_newsletter_email_msg_id(newsletter_id=newsletter_id, notify_action=notify_action)
newsletter_agent = newsletters.get_agent_class(newsletter_id=newsletter_id,
newsletter_id_name=newsletter_config['id_name'],
agent_id=newsletter_config['agent_id'],
@@ -93,7 +97,9 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
email_config=newsletter_config['email_config'],
subject=subject,
body=body,
message=message
message=message,
email_msg_id=email_msg_id,
email_reply_msg_id=email_reply_msg_id
)
# Set the newsletter state in the db
@@ -107,7 +113,8 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
end_date=newsletter_agent.end_date.format('YYYY-MM-DD'),
start_time=newsletter_agent.start_time,
end_time=newsletter_agent.end_time,
newsletter_uuid=newsletter_agent.uuid)
newsletter_uuid=newsletter_agent.uuid,
email_msg_id=email_msg_id)
# Send the notification
success = newsletter_agent.send()
@@ -118,7 +125,7 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
def set_notify_state(newsletter, notify_action, subject, body, message, filename,
start_date, end_date, start_time, end_time, newsletter_uuid):
start_date, end_date, start_time, end_time, newsletter_uuid, email_msg_id):
if newsletter and notify_action:
db = database.MonitorDatabase()
@@ -137,6 +144,7 @@ def set_notify_state(newsletter, notify_action, subject, body, message, filename
'end_date': end_date,
'start_time': start_time,
'end_time': end_time,
'email_msg_id': email_msg_id,
'filename': filename}
db.upsert(table_name='newsletter_log', key_dict=keys, value_dict=values)
@@ -153,6 +161,17 @@ def set_notify_success(newsletter_log_id):
db.upsert(table_name='newsletter_log', key_dict=keys, value_dict=values)
def get_last_newsletter_email_msg_id(newsletter_id, notify_action):
db = database.MonitorDatabase()
result = db.select_single('SELECT email_msg_id FROM newsletter_log '
'WHERE newsletter_id = ? AND notify_action = ? AND success = 1 '
'ORDER BY timestamp DESC LIMIT 1', [newsletter_id, notify_action])
if result:
return result['email_msg_id']
def get_newsletter(newsletter_uuid=None, newsletter_id_name=None):
db = database.MonitorDatabase()

View File

@@ -65,7 +65,8 @@ def available_notification_actions():
def get_agent_class(newsletter_id=None, newsletter_id_name=None, agent_id=None, config=None, email_config=None,
start_date=None, end_date=None, subject=None, body=None, message=None):
start_date=None, end_date=None, subject=None, body=None, message=None,
email_msg_id=None, email_reply_msg_id=None):
if str(agent_id).isdigit():
agent_id = int(agent_id)
@@ -77,7 +78,9 @@ def get_agent_class(newsletter_id=None, newsletter_id_name=None, agent_id=None,
'end_date': end_date,
'subject': subject,
'body': body,
'message': message}
'message': message,
'email_msg_id': email_msg_id,
'email_reply_msg_id': email_reply_msg_id}
if agent_id == 0:
return RecentlyAdded(**kwargs)
@@ -326,6 +329,7 @@ class Newsletter(object):
'time_frame': 7,
'time_frame_units': 'days',
'formatted': 1,
'threaded': 0,
'notifier_id': 0,
'filename': '',
'save_only': 0}
@@ -339,11 +343,15 @@ class Newsletter(object):
_TEMPLATE = ''
def __init__(self, newsletter_id=None, newsletter_id_name=None, config=None, email_config=None,
start_date=None, end_date=None, subject=None, body=None, message=None):
start_date=None, end_date=None, subject=None, body=None, message=None,
email_msg_id=None, email_reply_msg_id=None):
self.config = self.set_config(config=config, default=self._DEFAULT_CONFIG)
self.email_config = self.set_config(config=email_config, default=self._DEFAULT_EMAIL_CONFIG)
self.uuid = generate_newsletter_uuid()
self.email_msg_id = email_msg_id
self.email_reply_msg_id = email_reply_msg_id
self.newsletter_id = newsletter_id
self.newsletter_id_name = newsletter_id_name or ''
self.start_date = None
@@ -516,12 +524,16 @@ class Newsletter(object):
if plexpy.CONFIG.NEWSLETTER_SELF_HOSTED and plexpy.CONFIG.HTTP_BASE_URL:
plaintext += self._DEFAULT_BODY.format(**self.parameters)
email_reply_msg_id = self.email_reply_msg_id if self.config['threaded'] else None
if self.email_config['notifier_id']:
return send_notification(
notifier_id=self.email_config['notifier_id'],
subject=self.subject_formatted,
body=newsletter_stripped,
plaintext=plaintext
plaintext=plaintext,
msg_id=self.email_msg_id,
reply_msg_id=email_reply_msg_id
)
else:
@@ -529,7 +541,9 @@ class Newsletter(object):
return email.notify(
subject=self.subject_formatted,
body=newsletter_stripped,
plaintext=plaintext
plaintext=plaintext,
msg_id=self.email_msg_id,
reply_msg_id=email_reply_msg_id
)
elif self.config['notifier_id']:
return send_notification(

View File

@@ -486,20 +486,24 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
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]
parts = media_info.pop('parts')
media_part_info = next((p for p in parts if p['selected']), parts[0])
stream_video = stream_audio = stream_subtitle = False
if 'streams' in media_part_info:
for stream in media_part_info.pop('streams'):
if not stream_video and stream['type'] == '1':
media_part_info.update(stream)
stream_video = True
if not stream_audio and stream['type'] == '2':
media_part_info.update(stream)
stream_audio = True
if not stream_subtitle and stream['type'] == '3':
media_part_info.update(stream)
stream_subtitle = True
streams = media_part_info.pop('streams')
video_streams = [s for s in streams if s['type'] == '1']
audio_streams = [s for s in streams if s['type'] == '2']
subtitle_streams = [s for s in streams if s['type'] == '3']
if video_streams:
video_stream = next((s for s in video_streams if s['selected']), video_streams[0])
media_part_info.update(video_stream)
if audio_streams:
audio_stream = next((s for s in audio_streams if s['selected']), audio_streams[0])
media_part_info.update(audio_stream)
if subtitle_streams:
subtitle_stream = next((s for s in subtitle_streams if s['selected']), subtitle_streams[0])
media_part_info.update(subtitle_stream)
notify_params.update(media_info)
notify_params.update(media_part_info)
@@ -1035,6 +1039,7 @@ def build_notify_text(subject='', body='', notify_action=None, parameters=None,
# Remove the unwanted tags and strip any unmatch tags too.
subject = strip_tag(re.sub(pattern, '', subject), agent_id).strip(' \t\n\r')
body = strip_tag(re.sub(pattern, '', body), agent_id).strip(' \t\n\r')
script_args = []
if test:
return subject, body
@@ -1043,7 +1048,7 @@ def build_notify_text(subject='', body='', notify_action=None, parameters=None,
if agent_id == 15:
try:
script_args = [custom_formatter.format(arg, **parameters).decode(plexpy.SYS_ENCODING, 'ignore')
script_args = [custom_formatter.format(arg.decode(plexpy.SYS_ENCODING, 'ignore'), **parameters)
for arg in shlex.split(subject.encode(plexpy.SYS_ENCODING, 'ignore'))]
except LookupError as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in script argument. Using fallback." % e)
@@ -1051,26 +1056,48 @@ def build_notify_text(subject='', body='', notify_action=None, parameters=None,
except Exception as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom script arguments: %s. Using fallback." % e)
script_args = []
elif agent_id == 25:
if body:
try:
body = json.loads(body)
except ValueError as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom webhook json data: %s. Using fallback." % e)
body = ''
if body:
def str_format(s):
if isinstance(s, basestring):
return custom_formatter.format(unicode(s), **parameters)
return s
try:
body = json.dumps(helpers.traverse_map(body, str_format))
except LookupError as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in webhook data. Using fallback." % e)
body = ''
except Exception as e:
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom webhook data: %s. Using fallback." % e)
body = ''
else:
script_args = []
try:
subject = custom_formatter.format(unicode(subject), **parameters)
except LookupError as 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)
subject = unicode(default_subject).format(**parameters)
try:
subject = custom_formatter.format(unicode(subject), **parameters)
except LookupError as 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)
subject = unicode(default_subject).format(**parameters)
try:
body = custom_formatter.format(unicode(body), **parameters)
except LookupError as 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)
body = unicode(default_body).format(**parameters)
try:
body = custom_formatter.format(unicode(body), **parameters)
except LookupError as 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)
body = unicode(default_body).format(**parameters)
return subject, body, script_args
@@ -1431,6 +1458,10 @@ def get_themoviedb_info(rating_key=None, media_type=None, themoviedb_id=None):
class CustomFormatter(Formatter):
def __init__(self, default='{{{0}}}', default_format_spec='{{{0}:{1}}}'):
self.default = default
self.default_format_spec = default_format_spec
def convert_field(self, value, conversion):
if conversion is None:
return value
@@ -1459,4 +1490,13 @@ class CustomFormatter(Formatter):
else:
return value
else:
return super(CustomFormatter, self).format_field(value, format_spec)
try:
return super(CustomFormatter, self).format_field(value, format_spec)
except ValueError:
return self.default_format_spec.format(value[1:-1], format_spec)
def get_value(self, key, args, kwargs):
if isinstance(key, basestring):
return kwargs.get(key, self.default.format(key))
else:
return super(CustomFormatter, self).get_value(key, args, kwargs)

View File

@@ -91,7 +91,8 @@ AGENT_IDS = {'growl': 0,
'androidapp': 21,
'groupme': 22,
'mqtt': 23,
'zapier': 24
'zapier': 24,
'webhook': 25
}
DEFAULT_CUSTOM_CONDITIONS = [{'parameter': '', 'operator': '', 'value': ''}]
@@ -146,10 +147,10 @@ def available_notification_agents():
'name': 'xbmc',
'id': AGENT_IDS['xbmc']
},
{'label': 'Notify My Android',
'name': 'nma',
'id': AGENT_IDS['nma']
},
# {'label': 'Notify My Android',
# 'name': 'nma',
# 'id': AGENT_IDS['nma']
# },
{'label': 'MQTT',
'name': 'mqtt',
'id': AGENT_IDS['mqtt']
@@ -190,6 +191,10 @@ def available_notification_agents():
'name': 'twitter',
'id': AGENT_IDS['twitter']
},
{'label': 'Webhook',
'name': 'webhook',
'id': AGENT_IDS['webhook']
},
{'label': 'Zapier',
'name': 'zapier',
'id': AGENT_IDS['zapier']
@@ -386,6 +391,8 @@ def get_agent_class(agent_id=None, config=None):
return MQTT(config=config)
elif agent_id == 24:
return ZAPIER(config=config)
elif agent_id == 25:
return WEBHOOK(config=config)
else:
return Notifier(config=config)
else:
@@ -513,7 +520,7 @@ def add_notifier_config(agent_id=None, **kwargs):
'custom_conditions_logic': ''
}
if agent['name'] == 'scripts':
if agent['name'] in ('scripts', 'webhook'):
for a in available_notification_actions():
values[a['name'] + '_subject'] = ''
values[a['name'] + '_body'] = ''
@@ -774,7 +781,7 @@ class Notifier(object):
return self._DEFAULT_CONFIG.copy()
def notify(self, subject='', body='', action='', **kwargs):
if self.NAME != 'Script':
if self.NAME not in ('Script', 'Webhook'):
if not subject and self.config.get('incl_subject', True):
logger.error(u"Tautulli Notifiers :: %s notification subject cannot be blank." % self.NAME)
return
@@ -1302,13 +1309,20 @@ class EMAIL(Notifier):
msg.replace_header('Content-Transfer-Encoding', 'quoted-printable')
msg.set_payload(body, 'utf-8')
msg['Message-ID'] = email.utils.make_msgid()
msg_id = kwargs.get('msg_id', email.utils.make_msgid())
reply_msg_id = kwargs.get('reply_msg_id')
msg['Message-ID'] = msg_id
msg['Date'] = email.utils.formatdate(localtime=True)
msg['Subject'] = subject
msg['From'] = email.utils.formataddr((self.config['from_name'], self.config['from']))
msg['To'] = ','.join(self.config['to'])
msg['CC'] = ','.join(self.config['cc'])
if reply_msg_id:
msg["In-Reply-To"] = reply_msg_id
msg["References"] = reply_msg_id
recipients = self.config['to'] + self.config['cc'] + self.config['bcc']
mailserver = None
@@ -2971,7 +2985,9 @@ class SCRIPTS(Notifier):
'.sh': ''
}
self.arg_overrides = ('python2', 'python3', 'python', 'pythonw', 'php', 'ruby', 'perl')
self.pythonpath_override = 'nopythonpath'
self.pythonpath = True
self.prefix_overrides = ('python2', 'python3', 'python', 'pythonw', 'php', 'ruby', 'perl')
self.script_killed = False
def list_scripts(self):
@@ -2999,10 +3015,12 @@ class SCRIPTS(Notifier):
'PLEX_TOKEN': plexpy.CONFIG.PMS_TOKEN,
'TAUTULLI_URL': helpers.get_plexpy_url(hostname='localhost'),
'TAUTULLI_APIKEY': plexpy.CONFIG.API_KEY,
'TAUTULLI_ENCODING': plexpy.SYS_ENCODING,
'PYTHONPATH': (';' if os.name == 'nt' else ':').join(sys.path)
'TAUTULLI_ENCODING': plexpy.SYS_ENCODING
})
if self.pythonpath:
env['PYTHONPATH'] = os.pathsep.join([p for p in sys.path if p])
try:
process = subprocess.Popen(script,
stdin=subprocess.PIPE,
@@ -3094,9 +3112,15 @@ class SCRIPTS(Notifier):
if script_args: # and os.name == 'nt':
script_args = [arg.encode(plexpy.SYS_ENCODING, 'ignore') for arg in script_args]
# Allow overrides for PYTHONPATH
if prefix and script_args:
if script_args[0] == self.pythonpath_override:
self.pythonpath = False
del script_args[0]
# Allow overrides for shitty systems
if prefix and script_args:
if script_args[0] in self.arg_overrides:
if script_args[0] in self.prefix_overrides:
script[0] = script_args[0]
del script_args[0]
@@ -3527,6 +3551,53 @@ class TWITTER(Notifier):
return config_option
class WEBHOOK(Notifier):
"""
Webhook notifications
"""
NAME = 'Webhook'
_DEFAULT_CONFIG = {'hook': '',
'method': ''
}
def agent_notify(self, subject='', body='', action='', **kwargs):
if body:
try:
webhook_data = json.loads(body)
except ValueError as e:
logger.error(u"Tautulli Notifiers :: Invalid {name} json data: {e}".format(name=self.NAME, e=e))
return False
else:
webhook_data = None
headers = {'Content-type': 'application/json'}
return self.make_request(self.config['hook'], method=self.config['method'], headers=headers, json=webhook_data)
def return_config_options(self):
config_option = [{'label': 'Webhook URL',
'value': self.config['hook'],
'name': 'webhook_hook',
'description': 'Your Webhook URL.',
'input_type': 'text'
},
{'label': 'Webhook Method',
'value': self.config['method'],
'name': 'webhook_method',
'description': 'The Webhook HTTP request method.',
'input_type': 'select',
'select_options': {'': '',
'GET': 'GET',
'POST': 'POST',
'PUT': 'PUT',
'DELETE': 'DELETE'}
}
]
return config_option
class XBMC(Notifier):
"""
Kodi notifications

View File

@@ -211,17 +211,18 @@ class PlexTV(object):
def get_server_token(self):
servers = self.get_plextv_server_list(output_format='xml')
servers = self.get_plextv_resources(output_format='xml')
server_token = ''
try:
xml_head = servers.getElementsByTagName('Server')
xml_head = servers.getElementsByTagName('Device')
except Exception as e:
logger.warn(u"Tautulli PlexTV :: Unable to parse XML for get_server_token: %s." % e)
return None
for a in xml_head:
if helpers.get_xml_attr(a, 'machineIdentifier') == plexpy.CONFIG.PMS_IDENTIFIER:
if helpers.get_xml_attr(a, 'clientIdentifier') == plexpy.CONFIG.PMS_IDENTIFIER \
and 'server' in helpers.get_xml_attr(a, 'provides'):
server_token = helpers.get_xml_attr(a, 'accessToken')
break
@@ -685,6 +686,27 @@ class PlexTV(object):
def discover(self, include_cloud=True, all_servers=False):
""" Query plex for all servers online. Returns the ones you own in a selectize format """
# Try to discover localhost server
local_machine_identifier = None
request_handler = http_handler.HTTPHandler(urls='http://127.0.0.1:32400', timeout=1,
ssl_verify=False, silent=True)
request = request_handler.make_request(uri='/identity', request_type='GET', output_format='xml')
if request:
xml_head = request.getElementsByTagName('MediaContainer')[0]
local_machine_identifier = xml_head.getAttribute('machineIdentifier')
local_server = {'httpsRequired': '0',
'clientIdentifier': local_machine_identifier,
'label': 'Local',
'ip': '127.0.0.1',
'port': '32400',
'uri': 'http://127.0.0.1:32400',
'local': '1',
'value': '127.0.0.1:32400',
'is_cloud': False
}
servers = self.get_plextv_resources(include_https=True, output_format='xml')
clean_servers = []
@@ -704,8 +726,8 @@ class PlexTV(object):
for d in devices:
if helpers.get_xml_attr(d, 'presence') == '1' and \
helpers.get_xml_attr(d, 'owned') == '1' and \
helpers.get_xml_attr(d, 'provides') == 'server':
helpers.get_xml_attr(d, 'owned') == '1' and \
helpers.get_xml_attr(d, 'provides') == 'server':
is_cloud = (helpers.get_xml_attr(d, 'platform').lower() == 'cloud')
if not include_cloud and is_cloud:
@@ -717,14 +739,20 @@ class PlexTV(object):
if not all_servers:
# If this is a remote server don't show any local IPs.
if helpers.get_xml_attr(d, 'publicAddressMatches') == '0' and \
helpers.get_xml_attr(c, 'local') == '1':
helpers.get_xml_attr(c, 'local') == '1':
continue
# If this is a local server don't show any remote IPs.
if helpers.get_xml_attr(d, 'publicAddressMatches') == '1' and \
helpers.get_xml_attr(c, 'local') == '0':
helpers.get_xml_attr(c, 'local') == '0':
continue
if helpers.get_xml_attr(d, 'clientIdentifier') == local_machine_identifier:
local_server['httpsRequired'] = helpers.get_xml_attr(d, 'httpsRequired')
local_server['label'] = helpers.get_xml_attr(d, 'name')
clean_servers.append(local_server)
local_machine_identifier = None
server = {'httpsRequired': '1' if is_cloud else helpers.get_xml_attr(d, 'httpsRequired'),
'clientIdentifier': helpers.get_xml_attr(d, 'clientIdentifier'),
'label': helpers.get_xml_attr(d, 'name'),
@@ -732,11 +760,16 @@ class PlexTV(object):
'port': helpers.get_xml_attr(c, 'port'),
'uri': helpers.get_xml_attr(c, 'uri'),
'local': helpers.get_xml_attr(c, 'local'),
'value': helpers.get_xml_attr(c, 'address'),
'value': helpers.get_xml_attr(c, 'address') + ':' + helpers.get_xml_attr(c, 'port'),
'is_cloud': is_cloud
}
clean_servers.append(server)
if local_machine_identifier:
clean_servers.append(local_server)
clean_servers.sort(key=lambda s: (s['label'], -int(s['local']), s['ip']))
return clean_servers
def get_plex_downloads(self):
@@ -780,7 +813,7 @@ class PlexTV(object):
# Get proper download
releases = platform_downloads.get('releases', [{}])
release = next((r for r in releases if r['distro'] == plexpy.CONFIG.PMS_UPDATE_DISTRO and
release = next((r for r in releases if r['distro'] == plexpy.CONFIG.PMS_UPDATE_DISTRO and
r['build'] == plexpy.CONFIG.PMS_UPDATE_DISTRO_BUILD), releases[0])
download_info = {'update_available': v_new > v_old,

View File

@@ -1213,7 +1213,8 @@ class PmsConnect(object):
'video_width': helpers.get_xml_attr(stream, 'width'),
'video_language': helpers.get_xml_attr(stream, 'language'),
'video_language_code': helpers.get_xml_attr(stream, 'languageCode'),
'video_profile': helpers.get_xml_attr(stream, 'profile')
'video_profile': helpers.get_xml_attr(stream, 'profile'),
'selected': int(helpers.get_xml_attr(stream, 'selected') == '1')
})
elif helpers.get_xml_attr(stream, 'streamType') == '2':
@@ -1227,7 +1228,8 @@ class PmsConnect(object):
'audio_sample_rate': helpers.get_xml_attr(stream, 'samplingRate'),
'audio_language': helpers.get_xml_attr(stream, 'language'),
'audio_language_code': helpers.get_xml_attr(stream, 'languageCode'),
'audio_profile': helpers.get_xml_attr(stream, 'profile')
'audio_profile': helpers.get_xml_attr(stream, 'profile'),
'selected': int(helpers.get_xml_attr(stream, 'selected') == '1')
})
elif helpers.get_xml_attr(stream, 'streamType') == '3':
@@ -1239,14 +1241,16 @@ class PmsConnect(object):
'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')
'subtitle_language_code': helpers.get_xml_attr(stream, 'languageCode'),
'selected': int(helpers.get_xml_attr(stream, 'selected') == '1')
})
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': int(helpers.get_xml_attr(part, 'indexes') == 'sd'),
'streams': streams
'streams': streams,
'selected': int(helpers.get_xml_attr(part, 'selected') == '1')
})
audio_channels = helpers.get_xml_attr(media, 'audioChannels')

View File

@@ -1,2 +1,2 @@
PLEXPY_BRANCH = "beta"
PLEXPY_RELEASE_VERSION = "v2.1.16-beta"
PLEXPY_RELEASE_VERSION = "v2.1.19-beta"

View File

@@ -131,6 +131,30 @@ def getVersion():
return None, 'origin', common.BRANCH
def check_update(auto_update=False, notify=False):
check_github(auto_update=auto_update, notify=notify)
if not plexpy.CURRENT_VERSION:
plexpy.UPDATE_AVAILABLE = None
elif plexpy.COMMITS_BEHIND > 0 and plexpy.common.BRANCH in ('master', 'beta') and \
plexpy.common.RELEASE != plexpy.LATEST_RELEASE:
plexpy.UPDATE_AVAILABLE = 'release'
elif plexpy.COMMITS_BEHIND > 0 and plexpy.CURRENT_VERSION != plexpy.LATEST_VERSION and \
plexpy.INSTALL_TYPE != 'win':
plexpy.UPDATE_AVAILABLE = 'commit'
else:
plexpy.UPDATE_AVAILABLE = False
if plexpy.WIN_SYS_TRAY_ICON:
if plexpy.UPDATE_AVAILABLE:
icon = os.path.join(plexpy.PROG_DIR, 'data/interfaces/', plexpy.CONFIG.INTERFACE, 'images/logo_tray-update.ico')
hover_text = common.PRODUCT + ' - Update Available!'
else:
icon = os.path.join(plexpy.PROG_DIR, 'data/interfaces/', plexpy.CONFIG.INTERFACE, 'images/logo_tray.ico')
hover_text = common.PRODUCT + ' - No Update Available'
plexpy.WIN_SYS_TRAY_ICON.update(icon=icon, hover_text=hover_text)
def check_github(auto_update=False, notify=False):
plexpy.COMMITS_BEHIND = 0

View File

@@ -60,7 +60,8 @@ def on_connect():
plexpy.PLEX_SERVER_UP = True
plexpy.initialize_scheduler()
send_ping()
if plexpy.CONFIG.WEBSOCKET_MONITOR_PING_PONG:
send_ping()
def on_disconnect():

View File

@@ -110,6 +110,7 @@ class WebInterface(object):
"pms_is_cloud": plexpy.CONFIG.PMS_IS_CLOUD,
"pms_token": plexpy.CONFIG.PMS_TOKEN,
"pms_uuid": plexpy.CONFIG.PMS_UUID,
"pms_name": plexpy.CONFIG.PMS_NAME,
"logging_ignore_interval": plexpy.CONFIG.LOGGING_IGNORE_INTERVAL
}
@@ -277,7 +278,7 @@ class WebInterface(object):
def return_plex_xml_url(self, endpoint='', plextv=False, **kwargs):
kwargs['X-Plex-Token'] = plexpy.CONFIG.PMS_TOKEN
if plextv:
if plextv == 'true':
base_url = 'https://plex.tv'
else:
if plexpy.CONFIG.PMS_URL_OVERRIDE:
@@ -2802,6 +2803,7 @@ class WebInterface(object):
"pms_url_manual": checked(plexpy.CONFIG.PMS_URL_MANUAL),
"pms_uuid": plexpy.CONFIG.PMS_UUID,
"pms_web_url": plexpy.CONFIG.PMS_WEB_URL,
"pms_name": plexpy.CONFIG.PMS_NAME,
"date_format": plexpy.CONFIG.DATE_FORMAT,
"time_format": plexpy.CONFIG.TIME_FORMAT,
"week_start_monday": checked(plexpy.CONFIG.WEEK_START_MONDAY),
@@ -2853,7 +2855,8 @@ class WebInterface(object):
"newsletter_auth": plexpy.CONFIG.NEWSLETTER_AUTH,
"newsletter_password": plexpy.CONFIG.NEWSLETTER_PASSWORD,
"newsletter_inline_styles": checked(plexpy.CONFIG.NEWSLETTER_INLINE_STYLES),
"newsletter_custom_dir": plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR
"newsletter_custom_dir": plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR,
"win_sys_tray": checked(plexpy.CONFIG.WIN_SYS_TRAY)
}
return serve_template(templatename="settings.html", title="Settings", config=config, kwargs=kwargs)
@@ -2875,7 +2878,7 @@ class WebInterface(object):
"allow_guest_access", "cache_images", "http_proxy", "http_basic_auth", "notify_concurrent_by_ip",
"history_table_activity", "plexpy_auto_update",
"themoviedb_lookup", "tvmaze_lookup", "http_plex_admin",
"newsletter_self_hosted", "newsletter_inline_styles"
"newsletter_self_hosted", "newsletter_inline_styles", "win_sys_tray"
]
for checked_config in checked_configs:
if checked_config not in kwargs:
@@ -3800,16 +3803,15 @@ class WebInterface(object):
}
```
"""
versioncheck.check_github()
versioncheck.check_update()
if not plexpy.CURRENT_VERSION:
if plexpy.UPDATE_AVAILABLE is None:
return {'result': 'error',
'update': None,
'message': 'You are running an unknown version of Tautulli.'
}
elif plexpy.COMMITS_BEHIND > 0 and plexpy.common.BRANCH in ('master', 'beta') and \
plexpy.common.RELEASE != plexpy.LATEST_RELEASE:
elif plexpy.UPDATE_AVAILABLE == 'release':
return {'result': 'success',
'update': True,
'release': True,
@@ -3822,8 +3824,7 @@ class WebInterface(object):
plexpy.LATEST_RELEASE))
}
elif plexpy.COMMITS_BEHIND > 0 and plexpy.CURRENT_VERSION != plexpy.LATEST_VERSION and \
plexpy.INSTALL_TYPE != 'win':
elif plexpy.UPDATE_AVAILABLE == 'commit':
return {'result': 'success',
'update': True,
'release': False,
@@ -4614,7 +4615,8 @@ class WebInterface(object):
"video_language_code": "",
"video_profile": "high",
"video_ref_frames": "4",
"video_width": "1920"
"video_width": "1920",
"selected": 0
},
{
"audio_bitrate": "384",
@@ -4627,7 +4629,8 @@ class WebInterface(object):
"audio_profile": "",
"audio_sample_rate": "48000",
"id": "511664",
"type": "2"
"type": "2",
"selected": 1
},
{
"id": "511953",
@@ -4638,7 +4641,8 @@ class WebInterface(object):
"subtitle_language": "English",
"subtitle_language_code": "eng",
"subtitle_location": "external",
"type": "3"
"type": "3",
"selected": 1
}
]
}