Compare commits
26 Commits
v2.1.18
...
v2.1.19-be
Author | SHA1 | Date | |
---|---|---|---|
![]() |
4f397b032e | ||
![]() |
3a05b8ec69 | ||
![]() |
84ef02aa03 | ||
![]() |
5d82ed9415 | ||
![]() |
524183c2cb | ||
![]() |
53b361d410 | ||
![]() |
30c7c6592e | ||
![]() |
88b0b888a1 | ||
![]() |
c2dcd98939 | ||
![]() |
56e9845b2c | ||
![]() |
6b94292c7e | ||
![]() |
13dac9c1ea | ||
![]() |
4f4a66f7e7 | ||
![]() |
1bd7cf4d4c | ||
![]() |
b1ec49341e | ||
![]() |
aeccc2db71 | ||
![]() |
6be5397a2d | ||
![]() |
427201a4ce | ||
![]() |
5736e12bc3 | ||
![]() |
4648e3df5f | ||
![]() |
9dbb681f22 | ||
![]() |
658260f1f6 | ||
![]() |
f93e745f0b | ||
![]() |
0821c14aae | ||
![]() |
1b216a35d4 | ||
![]() |
7b4eadb140 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# 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)
|
## v2.1.18 (2018-07-27)
|
||||||
|
|
||||||
* Monitoring:
|
* Monitoring:
|
||||||
|
16
Tautulli.py
16
Tautulli.py
@@ -204,10 +204,10 @@ def main():
|
|||||||
|
|
||||||
# Force the http port if neccessary
|
# Force the http port if neccessary
|
||||||
if args.port:
|
if args.port:
|
||||||
http_port = args.port
|
plexpy.HTTP_PORT = args.port
|
||||||
logger.info('Using forced web server port: %i', http_port)
|
logger.info('Using forced web server port: %i', plexpy.HTTP_PORT)
|
||||||
else:
|
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
|
# Check if pyOpenSSL is installed. It is required for certificate generation
|
||||||
# and for CherryPy.
|
# and for CherryPy.
|
||||||
@@ -221,7 +221,7 @@ def main():
|
|||||||
|
|
||||||
# Try to start the server. Will exit here is address is already in use.
|
# Try to start the server. Will exit here is address is already in use.
|
||||||
web_config = {
|
web_config = {
|
||||||
'http_port': http_port,
|
'http_port': plexpy.HTTP_PORT,
|
||||||
'http_host': plexpy.CONFIG.HTTP_HOST,
|
'http_host': plexpy.CONFIG.HTTP_HOST,
|
||||||
'http_root': plexpy.CONFIG.HTTP_ROOT,
|
'http_root': plexpy.CONFIG.HTTP_ROOT,
|
||||||
'http_environment': plexpy.CONFIG.HTTP_ENVIRONMENT,
|
'http_environment': plexpy.CONFIG.HTTP_ENVIRONMENT,
|
||||||
@@ -238,8 +238,12 @@ def main():
|
|||||||
|
|
||||||
# Open webbrowser
|
# Open webbrowser
|
||||||
if plexpy.CONFIG.LAUNCH_BROWSER and not args.nolaunch and not plexpy.DEV:
|
if plexpy.CONFIG.LAUNCH_BROWSER and not args.nolaunch and not plexpy.DEV:
|
||||||
plexpy.launch_browser(plexpy.CONFIG.HTTP_HOST, http_port,
|
plexpy.launch_browser(plexpy.CONFIG.HTTP_HOST, plexpy.HTTP_PORT,
|
||||||
plexpy.CONFIG.HTTP_ROOT)
|
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
|
# Wait endlessy for a signal to happen
|
||||||
while True:
|
while True:
|
||||||
|
@@ -43,18 +43,18 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div id="ajaxMsg" class="ajaxMsg"></div>
|
<div id="ajaxMsg" class="ajaxMsg"></div>
|
||||||
% if _session['user_group'] == 'admin':
|
% 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;">
|
<div id="updatebar" style="display: none;">
|
||||||
You are running an unknown version of Tautulli.<br />
|
You are running an unknown version of Tautulli.<br />
|
||||||
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
|
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
|
||||||
</div>
|
</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;">
|
<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">
|
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 />
|
new release (${plexpy.LATEST_RELEASE})</a> of Tautulli is available!<br />
|
||||||
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
|
<a href="update">Update</a> or <a href="#" id="updateDismiss">Dismiss</a>
|
||||||
</div>
|
</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;">
|
<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">
|
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 />
|
newer version</a> of Tautulli is available!<br />
|
||||||
@@ -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><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>
|
<li role="separator" class="divider"></li>
|
||||||
% if plexpy.CONFIG.CHECK_GITHUB:
|
% 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
|
% endif
|
||||||
<li><a href="#" id="nav-restart"><i class="fa fa-fw fa-refresh"></i> Restart</a></li>
|
<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>
|
<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 () {
|
$('#nav-update').click(function () {
|
||||||
$(this).html('<i class="fa fa-fw fa-spin fa-refresh"></i> Checking');
|
$(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 () {
|
$('#donation_type a.crypto-donation').on('shown.bs.tab', function () {
|
||||||
|
BIN
data/interfaces/default/images/logo_tray-update.ico
Normal file
BIN
data/interfaces/default/images/logo_tray-update.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 112 KiB |
BIN
data/interfaces/default/images/logo_tray.ico
Normal file
BIN
data/interfaces/default/images/logo_tray.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 107 KiB |
@@ -100,7 +100,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<div class="home-padded-header padded-header">
|
<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;">
|
<ul class="nav nav-header nav-dashboard pull-right" style="margin-top: -3px;">
|
||||||
<li>
|
<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>
|
<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 class="modal-body">
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<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">
|
<input type="button" class="btn btn-bright" data-dismiss="modal" value="Close">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -846,6 +847,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#recently-added-count').tooltip({ container: 'body', placement: 'top', html: true });
|
$('#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>
|
</script>
|
||||||
% endif
|
% endif
|
||||||
% if _session['user_group'] == 'admin' and config['update_show_changelog']:
|
% if _session['user_group'] == 'admin' and config['update_show_changelog']:
|
||||||
|
@@ -173,7 +173,11 @@
|
|||||||
<input type="text" class="form-control" id="id_name" name="id_name" value="${newsletter['id_name']}" size="30">
|
<input type="text" class="form-control" id="id_name" name="id_name" value="${newsletter['id_name']}" size="30">
|
||||||
</div>
|
</div>
|
||||||
</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/<id_name></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/<id_name></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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="friendly_name">Description</label>
|
<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']}">
|
<input type="hidden" id="newsletter_config_formatted" name="newsletter_config_formatted" value="${newsletter['config']['formatted']}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" id="email_notifier_select">
|
<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>
|
<label for="newsletter_email_notifier_id">Email Notification Agent</label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
|
@@ -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" 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_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_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>
|
<li role="presentation"><a href="#tabs-test_notifications" aria-controls="tabs-test_notifications" role="tab" data-toggle="tab">Test Notifications</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,6 +190,8 @@
|
|||||||
<p class="help-block">
|
<p class="help-block">
|
||||||
% if notifier['agent_name'] == 'scripts':
|
% if notifier['agent_name'] == 'scripts':
|
||||||
Set the custom arguments passed to the script for each type of notification.
|
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:
|
% else:
|
||||||
Set the custom formatted text for each type of notification.
|
Set the custom formatted text for each type of notification.
|
||||||
% endif
|
% endif
|
||||||
@@ -225,6 +233,32 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
% endfor
|
% 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>
|
||||||
|
${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:
|
% else:
|
||||||
% for action in available_notification_actions:
|
% for action in available_notification_actions:
|
||||||
<li>
|
<li>
|
||||||
@@ -291,6 +325,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="help-block">Set custom arguments passed to the script.</p>
|
<p class="help-block">Set custom arguments passed to the script.</p>
|
||||||
</div>
|
</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:
|
% else:
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="test_subject">Subject Line</label>
|
<label for="test_subject">Subject Line</label>
|
||||||
@@ -305,7 +349,7 @@
|
|||||||
<label for="test_body">Message Body</label>
|
<label for="test_body">Message Body</label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-12">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<p class="help-block">Set a custom body.</p>
|
<p class="help-block">Set a custom body.</p>
|
||||||
|
@@ -9,7 +9,9 @@
|
|||||||
% for item in text:
|
% for item in text:
|
||||||
<div style="padding-bottom: 10px;">
|
<div style="padding-bottom: 10px;">
|
||||||
<h4>${item['media_type'].capitalize()}</h4>
|
<h4>${item['media_type'].capitalize()}</h4>
|
||||||
|
% if agent != 'webhook':
|
||||||
<pre>${item['subject']}</pre>
|
<pre>${item['subject']}</pre>
|
||||||
|
% endif
|
||||||
% if agent != 'scripts':
|
% if agent != 'scripts':
|
||||||
<pre>${item['body']}</pre>
|
<pre>${item['body']}</pre>
|
||||||
% endif
|
% endif
|
||||||
|
@@ -33,7 +33,7 @@
|
|||||||
<button id="menu_link_show_advanced_settings" class="btn btn-dark"><i class="fa fa-wrench"></i> Show Advanced</button>
|
<button id="menu_link_show_advanced_settings" class="btn btn-dark"><i class="fa fa-wrench"></i> Show Advanced</button>
|
||||||
% endif
|
% endif
|
||||||
% if config['check_github']:
|
% 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
|
% endif
|
||||||
<button id="menu_link_restart" class="btn btn-dark"><i class="fa fa-refresh"></i> Restart</button>
|
<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>
|
<button id="menu_link_shutdown" class="btn btn-dark"><i class="fa fa-power-off"></i> Shutdown</button>
|
||||||
@@ -430,6 +430,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="help-block">Note: Web interface changes require a restart.</p>
|
<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">
|
<div class="checkbox">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="launch_browser" id="launch_browser" value="1" ${config['launch_browser']}> Launch Browser on Startup
|
<input type="checkbox" name="launch_browser" id="launch_browser" value="1" ${config['launch_browser']}> Launch Browser on Startup
|
||||||
@@ -1929,7 +1937,7 @@ $(document).ready(function() {
|
|||||||
$('#menu_link_update_check').click(function() {
|
$('#menu_link_update_check').click(function() {
|
||||||
$(this).html('<i class="fa fa-spin fa-refresh"></i> Checking').prop('disabled', true);
|
$(this).html('<i class="fa fa-spin fa-refresh"></i> Checking').prop('disabled', true);
|
||||||
checkUpdate(function () {
|
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);
|
.prop('disabled', false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@@ -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
|
<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>
|
||||||
<div class="sub-header-count" style="margin-left: auto;margin-right: auto;font-size: 30px;text-align: center;">
|
<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> /
|
||||||
<% total_episodes = sum(season['episode_count'] for show in recently_added['show'] for season in show['season']) %>
|
<% 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>
|
<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>
|
</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%;">
|
<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;">
|
<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:
|
% if show['season_count'] > 1:
|
||||||
<em>${show['season_count']} seasons /</em>
|
<em>${show['season_count']} seasons / </em>
|
||||||
% endif
|
% endif
|
||||||
<% total_show_episodes = sum(s['episode_count'] for s in show['season']) %>
|
<% 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>
|
<em>${total_show_episodes} episode${'s' if total_show_episodes > 1 else ''}</em>
|
||||||
|
@@ -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
|
<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>
|
||||||
<div class="sub-header-count">
|
<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> /
|
||||||
<% total_episodes = sum(season['episode_count'] for show in recently_added['show'] for season in show['season']) %>
|
<% 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>
|
<span class="count">${total_episodes}</span> <span class="count-units">episode${'s' if total > 1 else ''}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -745,7 +745,7 @@
|
|||||||
<td class="card-info-body">
|
<td class="card-info-body">
|
||||||
<p class="nowrap mb5">
|
<p class="nowrap mb5">
|
||||||
% if show['season_count'] > 1:
|
% if show['season_count'] > 1:
|
||||||
<em>${show['season_count']} seasons /</em>
|
<em>${show['season_count']} seasons / </em>
|
||||||
% endif
|
% endif
|
||||||
<% total_show_episodes = sum(s['episode_count'] for s in show['season']) %>
|
<% 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>
|
<em>${total_show_episodes} episode${'s' if total_show_episodes > 1 else ''}</em>
|
||||||
|
@@ -4,47 +4,50 @@
|
|||||||
#
|
#
|
||||||
# INSTALLATION NOTES
|
# INSTALLATION NOTES
|
||||||
#
|
#
|
||||||
# 1. Rename this file as you want, ensuring that it ends in .service
|
# 1. Copy this file into your systemd service unit directory (often '/lib/systemd/system')
|
||||||
# e.g. 'tautulli.service'
|
# 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
|
# 2. Edit the new tautulli.service file with configuration settings as required.
|
||||||
# "CONFIGURATION NOTES" section shown below.
|
# More details in the "CONFIGURATION NOTES" section shown below.
|
||||||
#
|
#
|
||||||
# 3. Copy this file into your systemd service unit directory, which is
|
# 3. Enable boot-time autostart with the following commands:
|
||||||
# often '/lib/systemd/system'.
|
|
||||||
#
|
|
||||||
# 4. Enable boot-time autostart with the following commands:
|
|
||||||
# systemctl daemon-reload
|
# systemctl daemon-reload
|
||||||
# systemctl enable tautulli.service
|
# systemctl enable tautulli.service
|
||||||
#
|
#
|
||||||
# 5. Start now with the following command:
|
# 4. Start now with the following command:
|
||||||
# systemctl start tautulli.service
|
# systemctl start tautulli.service
|
||||||
#
|
#
|
||||||
# CONFIGURATION NOTES
|
# CONFIGURATION NOTES
|
||||||
#
|
#
|
||||||
# - The example settings in this file assume that you will run Tautulli as user: tautulli
|
# - 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 adduser --system --no-create-home tautulli
|
||||||
# sudo chown tautulli:nogroup -R /opt/Tautulli
|
# sudo chown tautulli:nogroup -R /opt/Tautulli
|
||||||
#
|
#
|
||||||
# - Option names (e.g. ExecStart=, Type=) appear to be case-sensitive)
|
|
||||||
#
|
|
||||||
# - Adjust ExecStart= to point to:
|
# - 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)
|
# 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)
|
# 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.
|
# - 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.
|
# - WantedBy= specifies which target (i.e. runlevel) to start Tautulli for.
|
||||||
# multi-user.target equates to runlevel 3 (multi-user text mode)
|
# multi-user.target equates to runlevel 3 (multi-user text mode)
|
||||||
# graphical.target equates to runlevel 5 (multi-user X11 graphical mode)
|
# graphical.target equates to runlevel 5 (multi-user X11 graphical mode)
|
||||||
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Tautulli - Stats for Plex Media Server usage
|
Description=Tautulli - Stats for Plex Media Server usage
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
[Service]
|
[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
|
GuessMainPID=no
|
||||||
Type=forking
|
Type=forking
|
||||||
User=tautulli
|
User=tautulli
|
||||||
|
2
lib/systray/__init__.py
Normal file
2
lib/systray/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__import__("pkg_resources").declare_namespace(__name__)
|
||||||
|
from .traybar import SysTrayIcon
|
314
lib/systray/traybar.py
Normal file
314
lib/systray/traybar.py
Normal 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)
|
199
lib/systray/win32_adapter.py
Normal file
199
lib/systray/win32_adapter.py
Normal 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
|
@@ -92,9 +92,11 @@ LATEST_VERSION = None
|
|||||||
COMMITS_BEHIND = None
|
COMMITS_BEHIND = None
|
||||||
PREV_RELEASE = None
|
PREV_RELEASE = None
|
||||||
LATEST_RELEASE = None
|
LATEST_RELEASE = None
|
||||||
|
UPDATE_AVAILABLE = False
|
||||||
|
|
||||||
UMASK = None
|
UMASK = None
|
||||||
|
|
||||||
|
HTTP_PORT = None
|
||||||
HTTP_ROOT = None
|
HTTP_ROOT = None
|
||||||
|
|
||||||
DEV = False
|
DEV = False
|
||||||
@@ -105,6 +107,8 @@ PLEX_SERVER_UP = None
|
|||||||
|
|
||||||
TRACKER = None
|
TRACKER = None
|
||||||
|
|
||||||
|
WIN_SYS_TRAY_ICON = None
|
||||||
|
|
||||||
|
|
||||||
def initialize(config_file):
|
def initialize(config_file):
|
||||||
with INIT_LOCK:
|
with INIT_LOCK:
|
||||||
@@ -256,7 +260,7 @@ def initialize(config_file):
|
|||||||
# Check for new versions
|
# Check for new versions
|
||||||
if CONFIG.CHECK_GITHUB_ON_STARTUP and CONFIG.CHECK_GITHUB:
|
if CONFIG.CHECK_GITHUB_ON_STARTUP and CONFIG.CHECK_GITHUB:
|
||||||
try:
|
try:
|
||||||
LATEST_VERSION = versioncheck.check_github()
|
LATEST_VERSION = versioncheck.check_update()
|
||||||
except:
|
except:
|
||||||
logger.exception(u"Unhandled exception")
|
logger.exception(u"Unhandled exception")
|
||||||
LATEST_VERSION = CURRENT_VERSION
|
LATEST_VERSION = CURRENT_VERSION
|
||||||
@@ -378,6 +382,51 @@ def launch_browser(host, port, root):
|
|||||||
logger.error(u"Could not launch browser: %s" % e)
|
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():
|
def initialize_scheduler():
|
||||||
"""
|
"""
|
||||||
Start the scheduled background tasks. Re-schedule if interval settings changed.
|
Start the scheduled background tasks. Re-schedule if interval settings changed.
|
||||||
@@ -391,7 +440,7 @@ def initialize_scheduler():
|
|||||||
# Update check
|
# Update check
|
||||||
github_minutes = CONFIG.CHECK_GITHUB_INTERVAL if CONFIG.CHECK_GITHUB_INTERVAL and CONFIG.CHECK_GITHUB else 0
|
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))
|
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
|
backup_hours = CONFIG.BACKUP_INTERVAL if 1 <= CONFIG.BACKUP_INTERVAL <= 24 else 6
|
||||||
@@ -668,7 +717,8 @@ def dbcheck():
|
|||||||
'CREATE TABLE IF NOT EXISTS newsletter_log (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp INTEGER, '
|
'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, '
|
'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, '
|
'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
|
# 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'
|
'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
|
# Upgrade newsletters table from earlier versions
|
||||||
try:
|
try:
|
||||||
c_db.execute('SELECT id_name FROM newsletters')
|
c_db.execute('SELECT id_name FROM newsletters')
|
||||||
@@ -1788,6 +1847,7 @@ def upgrade():
|
|||||||
|
|
||||||
|
|
||||||
def shutdown(restart=False, update=False, checkout=False):
|
def shutdown(restart=False, update=False, checkout=False):
|
||||||
|
logger.info(u"Stopping Tautulli web server...")
|
||||||
cherrypy.engine.exit()
|
cherrypy.engine.exit()
|
||||||
|
|
||||||
# Shutdown the websocket connection
|
# Shutdown the websocket connection
|
||||||
@@ -1826,6 +1886,9 @@ def shutdown(restart=False, update=False, checkout=False):
|
|||||||
logger.info(u"Removing pidfile %s", PIDFILE)
|
logger.info(u"Removing pidfile %s", PIDFILE)
|
||||||
os.remove(PIDFILE)
|
os.remove(PIDFILE)
|
||||||
|
|
||||||
|
if WIN_SYS_TRAY_ICON:
|
||||||
|
WIN_SYS_TRAY_ICON.shutdown()
|
||||||
|
|
||||||
if restart:
|
if restart:
|
||||||
logger.info(u"Tautulli is restarting...")
|
logger.info(u"Tautulli is restarting...")
|
||||||
|
|
||||||
|
@@ -122,7 +122,7 @@ class API2:
|
|||||||
|
|
||||||
else:
|
else:
|
||||||
self._api_msg = 'Invalid apikey'
|
self._api_msg = 'Invalid apikey'
|
||||||
|
|
||||||
if self._api_authenticated and self._api_cmd in self._api_valid_methods:
|
if self._api_authenticated and self._api_cmd in self._api_valid_methods:
|
||||||
self._api_msg = None
|
self._api_msg = None
|
||||||
self._api_kwargs = kwargs
|
self._api_kwargs = kwargs
|
||||||
@@ -311,8 +311,8 @@ class API2:
|
|||||||
self.backup_db()
|
self.backup_db()
|
||||||
else:
|
else:
|
||||||
# If the backup is less then 24 h old lets make a backup
|
# 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)
|
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)]):
|
and file_.endswith('.db') for file_ in os.listdir(plexpy.CONFIG.BACKUP_DIR)):
|
||||||
self.backup_db()
|
self.backup_db()
|
||||||
|
|
||||||
db = database.MonitorDatabase()
|
db = database.MonitorDatabase()
|
||||||
|
@@ -177,7 +177,8 @@ HW_ENCODERS = [
|
|||||||
'videotoolbox',
|
'videotoolbox',
|
||||||
'mediacodecndk',
|
'mediacodecndk',
|
||||||
'vaapi',
|
'vaapi',
|
||||||
'nvenc'
|
'nvenc',
|
||||||
|
'x264'
|
||||||
]
|
]
|
||||||
|
|
||||||
EXTRA_TYPES = {
|
EXTRA_TYPES = {
|
||||||
|
@@ -630,7 +630,8 @@ _CONFIG_DEFINITIONS = {
|
|||||||
'XBMC_ON_CONCURRENT': (int, 'XBMC', 0),
|
'XBMC_ON_CONCURRENT': (int, 'XBMC', 0),
|
||||||
'XBMC_ON_NEWDEVICE': (int, 'XBMC', 0),
|
'XBMC_ON_NEWDEVICE': (int, 'XBMC', 0),
|
||||||
'JWT_SECRET': (str, 'Advanced', ''),
|
'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']
|
_BLACKLIST_KEYS = ['_APITOKEN', '_TOKEN', '_KEY', '_SECRET', '_PASSWORD', '_APIKEY', '_ID', '_HOOK']
|
||||||
|
@@ -1115,3 +1115,20 @@ def grouper(iterable, n, fillvalue=None):
|
|||||||
# grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx
|
# grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx
|
||||||
args = [iter(iterable)] * n
|
args = [iter(iterable)] * n
|
||||||
return izip_longest(fillvalue=fillvalue, *args)
|
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
|
||||||
|
@@ -18,6 +18,7 @@ import time
|
|||||||
|
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
from apscheduler.triggers.cron import CronTrigger
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
import email.utils
|
||||||
|
|
||||||
import plexpy
|
import plexpy
|
||||||
import database
|
import database
|
||||||
@@ -86,6 +87,9 @@ def notify(newsletter_id=None, notify_action=None, **kwargs):
|
|||||||
body = newsletter_config['body']
|
body = newsletter_config['body']
|
||||||
message = newsletter_config['message']
|
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_agent = newsletters.get_agent_class(newsletter_id=newsletter_id,
|
||||||
newsletter_id_name=newsletter_config['id_name'],
|
newsletter_id_name=newsletter_config['id_name'],
|
||||||
agent_id=newsletter_config['agent_id'],
|
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'],
|
email_config=newsletter_config['email_config'],
|
||||||
subject=subject,
|
subject=subject,
|
||||||
body=body,
|
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
|
# 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'),
|
end_date=newsletter_agent.end_date.format('YYYY-MM-DD'),
|
||||||
start_time=newsletter_agent.start_time,
|
start_time=newsletter_agent.start_time,
|
||||||
end_time=newsletter_agent.end_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
|
# Send the notification
|
||||||
success = newsletter_agent.send()
|
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,
|
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:
|
if newsletter and notify_action:
|
||||||
db = database.MonitorDatabase()
|
db = database.MonitorDatabase()
|
||||||
@@ -137,6 +144,7 @@ def set_notify_state(newsletter, notify_action, subject, body, message, filename
|
|||||||
'end_date': end_date,
|
'end_date': end_date,
|
||||||
'start_time': start_time,
|
'start_time': start_time,
|
||||||
'end_time': end_time,
|
'end_time': end_time,
|
||||||
|
'email_msg_id': email_msg_id,
|
||||||
'filename': filename}
|
'filename': filename}
|
||||||
|
|
||||||
db.upsert(table_name='newsletter_log', key_dict=keys, value_dict=values)
|
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)
|
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):
|
def get_newsletter(newsletter_uuid=None, newsletter_id_name=None):
|
||||||
db = database.MonitorDatabase()
|
db = database.MonitorDatabase()
|
||||||
|
|
||||||
|
@@ -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,
|
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():
|
if str(agent_id).isdigit():
|
||||||
agent_id = int(agent_id)
|
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,
|
'end_date': end_date,
|
||||||
'subject': subject,
|
'subject': subject,
|
||||||
'body': body,
|
'body': body,
|
||||||
'message': message}
|
'message': message,
|
||||||
|
'email_msg_id': email_msg_id,
|
||||||
|
'email_reply_msg_id': email_reply_msg_id}
|
||||||
|
|
||||||
if agent_id == 0:
|
if agent_id == 0:
|
||||||
return RecentlyAdded(**kwargs)
|
return RecentlyAdded(**kwargs)
|
||||||
@@ -326,6 +329,7 @@ class Newsletter(object):
|
|||||||
'time_frame': 7,
|
'time_frame': 7,
|
||||||
'time_frame_units': 'days',
|
'time_frame_units': 'days',
|
||||||
'formatted': 1,
|
'formatted': 1,
|
||||||
|
'threaded': 0,
|
||||||
'notifier_id': 0,
|
'notifier_id': 0,
|
||||||
'filename': '',
|
'filename': '',
|
||||||
'save_only': 0}
|
'save_only': 0}
|
||||||
@@ -339,11 +343,15 @@ class Newsletter(object):
|
|||||||
_TEMPLATE = ''
|
_TEMPLATE = ''
|
||||||
|
|
||||||
def __init__(self, newsletter_id=None, newsletter_id_name=None, config=None, email_config=None,
|
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.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.email_config = self.set_config(config=email_config, default=self._DEFAULT_EMAIL_CONFIG)
|
||||||
self.uuid = generate_newsletter_uuid()
|
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 = newsletter_id
|
||||||
self.newsletter_id_name = newsletter_id_name or ''
|
self.newsletter_id_name = newsletter_id_name or ''
|
||||||
self.start_date = None
|
self.start_date = None
|
||||||
@@ -516,12 +524,16 @@ class Newsletter(object):
|
|||||||
if plexpy.CONFIG.NEWSLETTER_SELF_HOSTED and plexpy.CONFIG.HTTP_BASE_URL:
|
if plexpy.CONFIG.NEWSLETTER_SELF_HOSTED and plexpy.CONFIG.HTTP_BASE_URL:
|
||||||
plaintext += self._DEFAULT_BODY.format(**self.parameters)
|
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']:
|
if self.email_config['notifier_id']:
|
||||||
return send_notification(
|
return send_notification(
|
||||||
notifier_id=self.email_config['notifier_id'],
|
notifier_id=self.email_config['notifier_id'],
|
||||||
subject=self.subject_formatted,
|
subject=self.subject_formatted,
|
||||||
body=newsletter_stripped,
|
body=newsletter_stripped,
|
||||||
plaintext=plaintext
|
plaintext=plaintext,
|
||||||
|
msg_id=self.email_msg_id,
|
||||||
|
reply_msg_id=email_reply_msg_id
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -529,7 +541,9 @@ class Newsletter(object):
|
|||||||
return email.notify(
|
return email.notify(
|
||||||
subject=self.subject_formatted,
|
subject=self.subject_formatted,
|
||||||
body=newsletter_stripped,
|
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']:
|
elif self.config['notifier_id']:
|
||||||
return send_notification(
|
return send_notification(
|
||||||
|
@@ -1039,6 +1039,7 @@ def build_notify_text(subject='', body='', notify_action=None, parameters=None,
|
|||||||
# Remove the unwanted tags and strip any unmatch tags too.
|
# Remove the unwanted tags and strip any unmatch tags too.
|
||||||
subject = strip_tag(re.sub(pattern, '', subject), agent_id).strip(' \t\n\r')
|
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')
|
body = strip_tag(re.sub(pattern, '', body), agent_id).strip(' \t\n\r')
|
||||||
|
script_args = []
|
||||||
|
|
||||||
if test:
|
if test:
|
||||||
return subject, body
|
return subject, body
|
||||||
@@ -1047,7 +1048,7 @@ def build_notify_text(subject='', body='', notify_action=None, parameters=None,
|
|||||||
|
|
||||||
if agent_id == 15:
|
if agent_id == 15:
|
||||||
try:
|
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'))]
|
for arg in shlex.split(subject.encode(plexpy.SYS_ENCODING, 'ignore'))]
|
||||||
except LookupError as e:
|
except LookupError as e:
|
||||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in script argument. Using fallback." % e)
|
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in script argument. Using fallback." % e)
|
||||||
@@ -1055,26 +1056,48 @@ def build_notify_text(subject='', body='', notify_action=None, parameters=None,
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom script arguments: %s. Using fallback." % e)
|
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom script arguments: %s. Using fallback." % e)
|
||||||
script_args = []
|
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:
|
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:
|
try:
|
||||||
subject = custom_formatter.format(unicode(subject), **parameters)
|
body = custom_formatter.format(unicode(body), **parameters)
|
||||||
except LookupError as e:
|
except LookupError as e:
|
||||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in notification subject. Using fallback." % e)
|
logger.error(u"Tautulli NotificationHandler :: Unable to parse parameter %s in notification body. Using fallback." % e)
|
||||||
subject = unicode(default_subject).format(**parameters)
|
body = unicode(default_body).format(**parameters)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom notification subject: %s. Using fallback." % e)
|
logger.error(u"Tautulli NotificationHandler :: Unable to parse custom notification body: %s. Using fallback." % e)
|
||||||
subject = unicode(default_subject).format(**parameters)
|
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
|
return subject, body, script_args
|
||||||
|
|
||||||
|
@@ -91,7 +91,8 @@ AGENT_IDS = {'growl': 0,
|
|||||||
'androidapp': 21,
|
'androidapp': 21,
|
||||||
'groupme': 22,
|
'groupme': 22,
|
||||||
'mqtt': 23,
|
'mqtt': 23,
|
||||||
'zapier': 24
|
'zapier': 24,
|
||||||
|
'webhook': 25
|
||||||
}
|
}
|
||||||
|
|
||||||
DEFAULT_CUSTOM_CONDITIONS = [{'parameter': '', 'operator': '', 'value': ''}]
|
DEFAULT_CUSTOM_CONDITIONS = [{'parameter': '', 'operator': '', 'value': ''}]
|
||||||
@@ -146,10 +147,10 @@ def available_notification_agents():
|
|||||||
'name': 'xbmc',
|
'name': 'xbmc',
|
||||||
'id': AGENT_IDS['xbmc']
|
'id': AGENT_IDS['xbmc']
|
||||||
},
|
},
|
||||||
{'label': 'Notify My Android',
|
# {'label': 'Notify My Android',
|
||||||
'name': 'nma',
|
# 'name': 'nma',
|
||||||
'id': AGENT_IDS['nma']
|
# 'id': AGENT_IDS['nma']
|
||||||
},
|
# },
|
||||||
{'label': 'MQTT',
|
{'label': 'MQTT',
|
||||||
'name': 'mqtt',
|
'name': 'mqtt',
|
||||||
'id': AGENT_IDS['mqtt']
|
'id': AGENT_IDS['mqtt']
|
||||||
@@ -190,6 +191,10 @@ def available_notification_agents():
|
|||||||
'name': 'twitter',
|
'name': 'twitter',
|
||||||
'id': AGENT_IDS['twitter']
|
'id': AGENT_IDS['twitter']
|
||||||
},
|
},
|
||||||
|
{'label': 'Webhook',
|
||||||
|
'name': 'webhook',
|
||||||
|
'id': AGENT_IDS['webhook']
|
||||||
|
},
|
||||||
{'label': 'Zapier',
|
{'label': 'Zapier',
|
||||||
'name': 'zapier',
|
'name': 'zapier',
|
||||||
'id': AGENT_IDS['zapier']
|
'id': AGENT_IDS['zapier']
|
||||||
@@ -386,6 +391,8 @@ def get_agent_class(agent_id=None, config=None):
|
|||||||
return MQTT(config=config)
|
return MQTT(config=config)
|
||||||
elif agent_id == 24:
|
elif agent_id == 24:
|
||||||
return ZAPIER(config=config)
|
return ZAPIER(config=config)
|
||||||
|
elif agent_id == 25:
|
||||||
|
return WEBHOOK(config=config)
|
||||||
else:
|
else:
|
||||||
return Notifier(config=config)
|
return Notifier(config=config)
|
||||||
else:
|
else:
|
||||||
@@ -513,7 +520,7 @@ def add_notifier_config(agent_id=None, **kwargs):
|
|||||||
'custom_conditions_logic': ''
|
'custom_conditions_logic': ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if agent['name'] == 'scripts':
|
if agent['name'] in ('scripts', 'webhook'):
|
||||||
for a in available_notification_actions():
|
for a in available_notification_actions():
|
||||||
values[a['name'] + '_subject'] = ''
|
values[a['name'] + '_subject'] = ''
|
||||||
values[a['name'] + '_body'] = ''
|
values[a['name'] + '_body'] = ''
|
||||||
@@ -774,7 +781,7 @@ class Notifier(object):
|
|||||||
return self._DEFAULT_CONFIG.copy()
|
return self._DEFAULT_CONFIG.copy()
|
||||||
|
|
||||||
def notify(self, subject='', body='', action='', **kwargs):
|
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):
|
if not subject and self.config.get('incl_subject', True):
|
||||||
logger.error(u"Tautulli Notifiers :: %s notification subject cannot be blank." % self.NAME)
|
logger.error(u"Tautulli Notifiers :: %s notification subject cannot be blank." % self.NAME)
|
||||||
return
|
return
|
||||||
@@ -1302,13 +1309,20 @@ class EMAIL(Notifier):
|
|||||||
msg.replace_header('Content-Transfer-Encoding', 'quoted-printable')
|
msg.replace_header('Content-Transfer-Encoding', 'quoted-printable')
|
||||||
msg.set_payload(body, 'utf-8')
|
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['Date'] = email.utils.formatdate(localtime=True)
|
||||||
msg['Subject'] = subject
|
msg['Subject'] = subject
|
||||||
msg['From'] = email.utils.formataddr((self.config['from_name'], self.config['from']))
|
msg['From'] = email.utils.formataddr((self.config['from_name'], self.config['from']))
|
||||||
msg['To'] = ','.join(self.config['to'])
|
msg['To'] = ','.join(self.config['to'])
|
||||||
msg['CC'] = ','.join(self.config['cc'])
|
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']
|
recipients = self.config['to'] + self.config['cc'] + self.config['bcc']
|
||||||
|
|
||||||
mailserver = None
|
mailserver = None
|
||||||
@@ -2971,7 +2985,9 @@ class SCRIPTS(Notifier):
|
|||||||
'.sh': ''
|
'.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
|
self.script_killed = False
|
||||||
|
|
||||||
def list_scripts(self):
|
def list_scripts(self):
|
||||||
@@ -2999,10 +3015,12 @@ class SCRIPTS(Notifier):
|
|||||||
'PLEX_TOKEN': plexpy.CONFIG.PMS_TOKEN,
|
'PLEX_TOKEN': plexpy.CONFIG.PMS_TOKEN,
|
||||||
'TAUTULLI_URL': helpers.get_plexpy_url(hostname='localhost'),
|
'TAUTULLI_URL': helpers.get_plexpy_url(hostname='localhost'),
|
||||||
'TAUTULLI_APIKEY': plexpy.CONFIG.API_KEY,
|
'TAUTULLI_APIKEY': plexpy.CONFIG.API_KEY,
|
||||||
'TAUTULLI_ENCODING': plexpy.SYS_ENCODING,
|
'TAUTULLI_ENCODING': plexpy.SYS_ENCODING
|
||||||
'PYTHONPATH': os.pathsep.join([p for p in sys.path if p])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if self.pythonpath:
|
||||||
|
env['PYTHONPATH'] = os.pathsep.join([p for p in sys.path if p])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
process = subprocess.Popen(script,
|
process = subprocess.Popen(script,
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
@@ -3094,9 +3112,15 @@ class SCRIPTS(Notifier):
|
|||||||
if script_args: # and os.name == 'nt':
|
if script_args: # and os.name == 'nt':
|
||||||
script_args = [arg.encode(plexpy.SYS_ENCODING, 'ignore') for arg in script_args]
|
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
|
# Allow overrides for shitty systems
|
||||||
if prefix and script_args:
|
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]
|
script[0] = script_args[0]
|
||||||
del script_args[0]
|
del script_args[0]
|
||||||
|
|
||||||
@@ -3527,6 +3551,53 @@ class TWITTER(Notifier):
|
|||||||
return config_option
|
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):
|
class XBMC(Notifier):
|
||||||
"""
|
"""
|
||||||
Kodi notifications
|
Kodi notifications
|
||||||
|
@@ -211,17 +211,18 @@ class PlexTV(object):
|
|||||||
|
|
||||||
|
|
||||||
def get_server_token(self):
|
def get_server_token(self):
|
||||||
servers = self.get_plextv_server_list(output_format='xml')
|
servers = self.get_plextv_resources(output_format='xml')
|
||||||
server_token = ''
|
server_token = ''
|
||||||
|
|
||||||
try:
|
try:
|
||||||
xml_head = servers.getElementsByTagName('Server')
|
xml_head = servers.getElementsByTagName('Device')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warn(u"Tautulli PlexTV :: Unable to parse XML for get_server_token: %s." % e)
|
logger.warn(u"Tautulli PlexTV :: Unable to parse XML for get_server_token: %s." % e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for a in xml_head:
|
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')
|
server_token = helpers.get_xml_attr(a, 'accessToken')
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -812,7 +813,7 @@ class PlexTV(object):
|
|||||||
|
|
||||||
# Get proper download
|
# Get proper download
|
||||||
releases = platform_downloads.get('releases', [{}])
|
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])
|
r['build'] == plexpy.CONFIG.PMS_UPDATE_DISTRO_BUILD), releases[0])
|
||||||
|
|
||||||
download_info = {'update_available': v_new > v_old,
|
download_info = {'update_available': v_new > v_old,
|
||||||
|
@@ -1,2 +1,2 @@
|
|||||||
PLEXPY_BRANCH = "master"
|
PLEXPY_BRANCH = "beta"
|
||||||
PLEXPY_RELEASE_VERSION = "v2.1.18"
|
PLEXPY_RELEASE_VERSION = "v2.1.19-beta"
|
||||||
|
@@ -131,6 +131,30 @@ def getVersion():
|
|||||||
return None, 'origin', common.BRANCH
|
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):
|
def check_github(auto_update=False, notify=False):
|
||||||
plexpy.COMMITS_BEHIND = 0
|
plexpy.COMMITS_BEHIND = 0
|
||||||
|
|
||||||
|
@@ -278,7 +278,7 @@ class WebInterface(object):
|
|||||||
def return_plex_xml_url(self, endpoint='', plextv=False, **kwargs):
|
def return_plex_xml_url(self, endpoint='', plextv=False, **kwargs):
|
||||||
kwargs['X-Plex-Token'] = plexpy.CONFIG.PMS_TOKEN
|
kwargs['X-Plex-Token'] = plexpy.CONFIG.PMS_TOKEN
|
||||||
|
|
||||||
if plextv:
|
if plextv == 'true':
|
||||||
base_url = 'https://plex.tv'
|
base_url = 'https://plex.tv'
|
||||||
else:
|
else:
|
||||||
if plexpy.CONFIG.PMS_URL_OVERRIDE:
|
if plexpy.CONFIG.PMS_URL_OVERRIDE:
|
||||||
@@ -2855,7 +2855,8 @@ class WebInterface(object):
|
|||||||
"newsletter_auth": plexpy.CONFIG.NEWSLETTER_AUTH,
|
"newsletter_auth": plexpy.CONFIG.NEWSLETTER_AUTH,
|
||||||
"newsletter_password": plexpy.CONFIG.NEWSLETTER_PASSWORD,
|
"newsletter_password": plexpy.CONFIG.NEWSLETTER_PASSWORD,
|
||||||
"newsletter_inline_styles": checked(plexpy.CONFIG.NEWSLETTER_INLINE_STYLES),
|
"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)
|
return serve_template(templatename="settings.html", title="Settings", config=config, kwargs=kwargs)
|
||||||
@@ -2877,7 +2878,7 @@ class WebInterface(object):
|
|||||||
"allow_guest_access", "cache_images", "http_proxy", "http_basic_auth", "notify_concurrent_by_ip",
|
"allow_guest_access", "cache_images", "http_proxy", "http_basic_auth", "notify_concurrent_by_ip",
|
||||||
"history_table_activity", "plexpy_auto_update",
|
"history_table_activity", "plexpy_auto_update",
|
||||||
"themoviedb_lookup", "tvmaze_lookup", "http_plex_admin",
|
"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:
|
for checked_config in checked_configs:
|
||||||
if checked_config not in kwargs:
|
if checked_config not in kwargs:
|
||||||
@@ -3802,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',
|
return {'result': 'error',
|
||||||
'update': None,
|
'update': None,
|
||||||
'message': 'You are running an unknown version of Tautulli.'
|
'message': 'You are running an unknown version of Tautulli.'
|
||||||
}
|
}
|
||||||
|
|
||||||
elif plexpy.COMMITS_BEHIND > 0 and plexpy.common.BRANCH in ('master', 'beta') and \
|
elif plexpy.UPDATE_AVAILABLE == 'release':
|
||||||
plexpy.common.RELEASE != plexpy.LATEST_RELEASE:
|
|
||||||
return {'result': 'success',
|
return {'result': 'success',
|
||||||
'update': True,
|
'update': True,
|
||||||
'release': True,
|
'release': True,
|
||||||
@@ -3824,8 +3824,7 @@ class WebInterface(object):
|
|||||||
plexpy.LATEST_RELEASE))
|
plexpy.LATEST_RELEASE))
|
||||||
}
|
}
|
||||||
|
|
||||||
elif plexpy.COMMITS_BEHIND > 0 and plexpy.CURRENT_VERSION != plexpy.LATEST_VERSION and \
|
elif plexpy.UPDATE_AVAILABLE == 'commit':
|
||||||
plexpy.INSTALL_TYPE != 'win':
|
|
||||||
return {'result': 'success',
|
return {'result': 'success',
|
||||||
'update': True,
|
'update': True,
|
||||||
'release': False,
|
'release': False,
|
||||||
|
Reference in New Issue
Block a user