Compare commits

...

16 Commits

Author SHA1 Message Date
JonnyWong16
e1e5a050c2 v2.0.15-beta 2018-01-27 11:08:45 -08:00
JonnyWong16
58996c1115 Unused now time 2018-01-27 10:59:48 -08:00
JonnyWong16
7301fe5f6e Remove 24 hour limit for recently added 2018-01-26 12:29:38 -08:00
JonnyWong16
a27c423569 Line up cards on the homepage 2018-01-24 21:37:02 -08:00
JonnyWong16
19680d3bc7 Refresh stream location on activity cards 2018-01-24 21:14:18 -08:00
JonnyWong16
ecaca4e5dc Change hover text from "View in" to "View on" 2018-01-24 21:07:12 -08:00
JonnyWong16
191de0b577 Add "View On" to Plex Web click-through 2018-01-24 21:04:34 -08:00
JonnyWong16
ebcc073b32 Add more server notification parameters. Rename plexpy parameters to tautulli. 2018-01-22 17:50:48 -08:00
JonnyWong16
043b3fd57b Update state for "Check server response" task 2018-01-22 13:44:51 -08:00
JonnyWong16
dd50502dcb Update Discord link to welcome channel 2018-01-22 11:27:04 -08:00
JonnyWong16
f159a1014d Don't add view_offset to live progress bar 2018-01-21 19:46:23 -08:00
JonnyWong16
abb801535c Add line break for Live progress 2018-01-21 16:09:48 -08:00
JonnyWong16
2732dbf1b1 Fix progress time for live tv 2018-01-21 16:07:32 -08:00
JonnyWong16
095d893005 Improve Live TV info on activity cards 2018-01-21 15:54:38 -08:00
JonnyWong16
5d8455d141 Get rating key for live sessions from websocket data 2018-01-21 13:09:02 -08:00
JonnyWong16
aa3450bfcc Add Labels and Collections to notification parameters 2018-01-20 20:01:01 -08:00
14 changed files with 161 additions and 91 deletions

View File

@@ -1,5 +1,17 @@
# Changelog # Changelog
## v2.0.15-beta (2018-01-27)
* Monitoring:
* Fix: Live TV sessions not being stopped in History.
* Fix: Stream location showing as "unknown" on the activity cards.
* New: Improved Live TV details on the activity cards.
* Notifications:
* New: Added labels and collections to notification parameters.
* New: Added more server details to notification parameters.
* Change: Renamed "PlexPy" update notification parameters to "Tautulli".
## v2.0.14-beta (2018-01-20) ## v2.0.14-beta (2018-01-20)
* Monitoring: * Monitoring:

View File

@@ -1,6 +1,6 @@
# Tautulli # Tautulli
[![Discord](https://img.shields.io/badge/Discord-Tautulli-7289DA.svg?style=flat-square)](https://discord.gg/36ggawe) [![Discord](https://img.shields.io/badge/Discord-Tautulli-7289DA.svg?style=flat-square)](https://discord.gg/tQcWEUp)
[![Reddit](https://img.shields.io/badge/Reddit-Tautulli-FF5700.svg?style=flat-square)](https://www.reddit.com/r/Tautulli/) [![Reddit](https://img.shields.io/badge/Reddit-Tautulli-FF5700.svg?style=flat-square)](https://www.reddit.com/r/Tautulli/)
[![Plex Forums](https://img.shields.io/badge/Plex%20Forums-Tautulli-E5A00D.svg?style=flat-square)](https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program) [![Plex Forums](https://img.shields.io/badge/Plex%20Forums-Tautulli-E5A00D.svg?style=flat-square)](https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program)
@@ -49,7 +49,7 @@ This project is based on code from [Headphones](https://github.com/rembo10/headp
- Checking the [Wiki](https://github.com/JonnyWong16/plexpy/wiki) for - Checking the [Wiki](https://github.com/JonnyWong16/plexpy/wiki) for
[ [Installation] ](https://github.com/JonnyWong16/plexpy/wiki/Installation) and [ [Installation] ](https://github.com/JonnyWong16/plexpy/wiki/Installation) and
[ [FAQs] ](https://github.com/JonnyWong16/plexpy/wiki/Frequently-Asked-Questions-(FAQ)). [ [FAQs] ](https://github.com/JonnyWong16/plexpy/wiki/Frequently-Asked-Questions-(FAQ)).
- For basic questions try asking on [Discord](https://discord.gg/36ggawe), [Reddit](https://www.reddit.com/r/Tautulli), or the [Plex Forums](https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program) first before opening an issue. - For basic questions try asking on [Discord](https://discord.gg/tQcWEUp), [Reddit](https://www.reddit.com/r/Tautulli), or the [Plex Forums](https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program) first before opening an issue.
##### If nothing has worked: ##### If nothing has worked:

View File

@@ -84,7 +84,7 @@ DOCUMENTATION :: END
<tr> <tr>
<td>Support:</td> <td>Support:</td>
<td> <td>
<a class="no-highlight support-modal-link" href="${anon_url('https://discord.gg/36ggawe')}" target="_blank">Tautulli Discord Server</a> | <a class="no-highlight support-modal-link" href="${anon_url('https://discord.gg/tQcWEUp')}" target="_blank">Tautulli Discord Server</a> |
<a class="no-highlight support-modal-link" href="${anon_url('https://www.reddit.com/r/Tautulli')}" target="_blank">Tautulli Subreddit</a> | <a class="no-highlight support-modal-link" href="${anon_url('https://www.reddit.com/r/Tautulli')}" target="_blank">Tautulli Subreddit</a> |
<a class="no-highlight support-modal-link" href="${anon_url('https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program')}" target="_blank">Plex Forums</a> <a class="no-highlight support-modal-link" href="${anon_url('https://forums.plex.tv/discussion/169591/plexpy-another-plex-monitoring-program')}" target="_blank">Plex Forums</a>
</td> </td>

View File

@@ -706,8 +706,8 @@ a .users-poster-face:hover {
height: 290px; height: 290px;
min-width: 350px; min-width: 350px;
max-width: 500px; max-width: 500px;
margin-right: 20px; margin-right: 25px;
margin-bottom: 20px; margin-bottom: 25px;
} }
.dashboard-activity-container { .dashboard-activity-container {
height: 240px; height: 240px;
@@ -1124,8 +1124,8 @@ a .dashboard-activity-metadata-user-thumb:hover {
height: 160px; height: 160px;
min-width: 350px; min-width: 350px;
max-width: 500px; max-width: 500px;
margin-right: 20px; margin-right: 25px;
margin-bottom: 20px; margin-bottom: 25px;
} }
.dashboard-stats-container { .dashboard-stats-container {
height: 160px; height: 160px;
@@ -1759,6 +1759,18 @@ a:hover .dashboard-recent-media-cover {
opacity: 0; opacity: 0;
transition: opacity .3s; transition: opacity .3s;
} }
.summary-poster-face-overlay span:before {
content: "View On";
color: #999;
font-size: 13px;
font-weight: bold;
text-transform: uppercase;
text-align: center;
display: block;
position: absolute;
top: calc(50% - 34px);
width: 100%;
}
a:hover .summary-poster-face .summary-poster-face-overlay, a:hover .summary-poster-face .summary-poster-face-overlay,
a:hover .summary-poster-face-episode .summary-poster-face-overlay, a:hover .summary-poster-face-episode .summary-poster-face-overlay,
a:hover .summary-poster-face-track .summary-poster-face-overlay, a:hover .summary-poster-face-track .summary-poster-face-overlay,

View File

@@ -201,7 +201,7 @@ DOCUMENTATION :: END
<li class="dashboard-activity-info-item"> <li class="dashboard-activity-info-item">
<div class="sub-heading">Container</div> <div class="sub-heading">Container</div>
<div class="sub-value" id="transcode_container-${sk}"> <div class="sub-value" id="transcode_container-${sk}">
% if data.get('stream_container_decision') == 'transcode': % if data['stream_container_decision'] == 'transcode':
Transcode (${data['container'].upper()} <i class="fa fa-long-arrow-right"></i> ${data['stream_container'].upper()}) Transcode (${data['container'].upper()} <i class="fa fa-long-arrow-right"></i> ${data['stream_container'].upper()})
% else: % else:
Direct Play (${data['container'].upper()}) Direct Play (${data['container'].upper()})
@@ -213,13 +213,13 @@ DOCUMENTATION :: END
<div class="sub-heading">Video</div> <div class="sub-heading">Video</div>
<div class="sub-value" id="video_decision-${sk}"> <div class="sub-value" id="video_decision-${sk}">
% if data['media_type'] in ('movie', 'episode', 'clip'): % if data['media_type'] in ('movie', 'episode', 'clip'):
% if data.get('stream_video_decision') == 'transcode': % if data['stream_video_decision'] == 'transcode':
<% <%
hw_d = ' (HW)' if data['transcode_hw_decoding'] else '' hw_d = ' (HW)' if data['transcode_hw_decoding'] else ''
hw_e = ' (HW)' if data['transcode_hw_encoding'] else '' hw_e = ' (HW)' if data['transcode_hw_encoding'] else ''
%> %>
Transcode (${data['video_codec'].upper()}${hw_d} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])} <i class="fa fa-long-arrow-right"></i> ${data['stream_video_codec'].upper()}${hw_e} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])}) Transcode (${data['video_codec'].upper()}${hw_d} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])} <i class="fa fa-long-arrow-right"></i> ${data['stream_video_codec'].upper()}${hw_e} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
% elif data.get('stream_video_decision') == 'copy': % elif data['stream_video_decision'] == 'copy':
Direct Stream (${data['stream_video_codec'].upper()} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])}) Direct Stream (${data['stream_video_codec'].upper()} ${VIDEO_RESOLUTION_OVERRIDES.get(data['stream_video_resolution'], data['stream_video_resolution'])})
% else: % else:
Direct Play (${data['video_codec'].upper()} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])}) Direct Play (${data['video_codec'].upper()} ${VIDEO_RESOLUTION_OVERRIDES.get(data['video_resolution'], data['video_resolution'])})
@@ -234,9 +234,9 @@ DOCUMENTATION :: END
<li class="dashboard-activity-info-item"> <li class="dashboard-activity-info-item">
<div class="sub-heading">Audio</div> <div class="sub-heading">Audio</div>
<div class="sub-value" id="audio_decision-${sk}"> <div class="sub-value" id="audio_decision-${sk}">
% if data.get('stream_audio_decision') == 'transcode': % if data['stream_audio_decision'] == 'transcode':
Transcode (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} <i class="fa fa-long-arrow-right"></i> ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()}) Transcode (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()} <i class="fa fa-long-arrow-right"></i> ${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% elif data.get('stream_audio_decision') == 'copy': % elif data['stream_audio_decision'] == 'copy':
Direct Stream (${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()}) Direct Stream (${AUDIO_CODEC_OVERRIDES.get(data['stream_audio_codec'], data['stream_audio_codec'].upper())} ${data['stream_audio_channel_layout'].split('(')[0].capitalize()})
% else: % else:
Direct Play (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()}) Direct Play (${AUDIO_CODEC_OVERRIDES.get(data['audio_codec'], data['audio_codec'].upper())} ${data['audio_channel_layout'].split('(')[0].capitalize()})
@@ -270,7 +270,7 @@ DOCUMENTATION :: END
<div class="sub-heading">Location</div> <div class="sub-heading">Location</div>
<div class="sub-value time-right"> <div class="sub-value time-right">
% if data['ip_address'] != 'N/A': % if data['ip_address'] != 'N/A':
${data['location'].upper()}: <span class="ip-container"><span class="ip-address">${data['ip_address']}</span></span> <span id="location-${sk}">${data['location'].upper()}</span>: <span class="ip-container"><span class="ip-address">${data['ip_address']}</span></span>
<a href="#" class="external_ip-modal" data-toggle="modal" data-target="#ip-info-modal" data-ip="${data['ip_address']}"> <a href="#" class="external_ip-modal" data-toggle="modal" data-target="#ip-info-modal" data-ip="${data['ip_address']}">
<span id="external_ip-${sk}" class="external-ip-tooltip" data-toggle="tooltip" title="Lookup External IP" style="display: none;"><i class="fa fa-map-marker"></i></span> <span id="external_ip-${sk}" class="external-ip-tooltip" data-toggle="tooltip" title="Lookup External IP" style="display: none;"><i class="fa fa-map-marker"></i></span>
</a> </a>
@@ -312,7 +312,9 @@ DOCUMENTATION :: END
</div> </div>
% if data['media_type'] != 'photo': % if data['media_type'] != 'photo':
<div class="dashboard-activity-info-time"> <div class="dashboard-activity-info-time">
% if data['view_offset']: % if data['live'] == 1:
<br />Live
% elif data['view_offset']:
ETA: ETA:
<span id="stream-eta-${sk}"> <span id="stream-eta-${sk}">
<script> <script>
@@ -340,8 +342,12 @@ DOCUMENTATION :: END
</div> </div>
<div class="dashboard-activity-progress"> <div class="dashboard-activity-progress">
<div class="dashboard-activity-progress-bar"> <div class="dashboard-activity-progress-bar">
% if data['live'] == 1:
<div id="progress-bar-${sk}" class="progress-bar" style="width: 100%" data-toggle="tooltip" title="Stream Progress Live">Live</div>
% else:
<div id="buffer-bar-${sk}" class="buffer-bar" style="width: ${data['transcode_progress']}%" data-toggle="tooltip" title="Transcoder Progress ${data['transcode_progress']}%">${data['transcode_progress']}%</div> <div id="buffer-bar-${sk}" class="buffer-bar" style="width: ${data['transcode_progress']}%" data-toggle="tooltip" title="Transcoder Progress ${data['transcode_progress']}%">${data['transcode_progress']}%</div>
<div id="progress-bar-${sk}" class="progress-bar" style="width: ${data['progress_percent']}%" data-last_view_offset="${data['view_offset']}" data-view_offset="${data['view_offset']}" data-stream_duration="${data['stream_duration']}" data-state="${data['state']}" data-toggle="tooltip" title="Stream Progress ${data['progress_percent']}%">${data['progress_percent']}%</div> <div id="progress-bar-${sk}" class="progress-bar" style="width: ${data['progress_percent']}%" data-last_view_offset="${data['view_offset']}" data-view_offset="${data['view_offset']}" data-stream_duration="${data['stream_duration']}" data-state="${data['state']}" data-toggle="tooltip" title="Stream Progress ${data['progress_percent']}%">${data['progress_percent']}%</div>
% endif
</div> </div>
</div> </div>
</div> </div>
@@ -389,7 +395,11 @@ DOCUMENTATION :: END
</div> </div>
</div> </div>
<div class="dashboard-activity-metadata-subtitle-container"> <div class="dashboard-activity-metadata-subtitle-container">
% if data['channel_stream'] == 0: % if data['live'] == 1:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Plex Live TV">
<i class="fa fa-fw fa-television"></i>&nbsp;
</div>
% elif data['channel_stream'] == 0:
<div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="${data['media_type'].capitalize()}"> <div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="${data['media_type'].capitalize()}">
% if data['media_type'] == 'movie': % if data['media_type'] == 'movie':
<i class="fa fa-fw fa-film"></i>&nbsp; <i class="fa fa-fw fa-film"></i>&nbsp;
@@ -404,12 +414,14 @@ DOCUMENTATION :: END
% endif % endif
</div> </div>
% else: % else:
<div id="media-type-${sk}" title="Channel"> <div id="media-type-${sk}" class="dashboard-activity-metadata-media_type-icon" title="Channel">
<i class="fa fa-fw fa-cloud"></i>&nbsp; <i class="fa fa-fw fa-cloud"></i>&nbsp;
</div> </div>
% endif % endif
<div class="dashboard-activity-metadata-subtitle"> <div class="dashboard-activity-metadata-subtitle">
% if data['channel_stream'] == 0: % if data['live'] == 1:
<span title="Plex Live TV" class="sub-heading">Plex Live TV</span>
% elif data['channel_stream'] == 0:
% if data['media_type'] == 'movie': % if data['media_type'] == 'movie':
<span title="${data['year']}" class="sub-heading">${data['year']}</span> <span title="${data['year']}" class="sub-heading">${data['year']}</span>
% elif data['media_type'] == 'episode': % elif data['media_type'] == 'episode':

View File

@@ -485,6 +485,8 @@
$('#optimized_version-' + key).html(s.optimized_version_profile + ' (' + s.optimized_version_title + ')'); $('#optimized_version-' + key).html(s.optimized_version_profile + ' (' + s.optimized_version_title + ')');
$('#synced_quality_profile-' + key).html(s.synced_quality_profile); $('#synced_quality_profile-' + key).html(s.synced_quality_profile);
$('#location-' + key).html(s.location.toUpperCase());
if (s.media_type !== 'photo' && parseInt(s.bandwidth)) { if (s.media_type !== 'photo' && parseInt(s.bandwidth)) {
var bw = parseInt(s.bandwidth); var bw = parseInt(s.bandwidth);
if (bw !== "Unknown") { if (bw !== "Unknown") {
@@ -510,7 +512,7 @@
.attr('data-original-title', 'Transcoder Progress ' + s.transcode_progress + '%'); .attr('data-original-title', 'Transcoder Progress ' + s.transcode_progress + '%');
var progress_bar = $('#progress-bar-' + key); var progress_bar = $('#progress-bar-' + key);
progress_bar.data('state', s.state); progress_bar.data('state', s.state);
if (progress_bar.data('last_view_offset') !== s.view_offset) { if (progress_bar.data('last_view_offset') && progress_bar.data('last_view_offset') !== s.view_offset) {
progress_bar.data('last_view_offset', s.view_offset).data('view_offset', s.view_offset); progress_bar.data('last_view_offset', s.view_offset).data('view_offset', s.view_offset);
} }

View File

@@ -117,9 +117,9 @@ DOCUMENTATION :: END
<div class="col-md-9"> <div class="col-md-9">
<div class="summary-content-poster hidden-xs hidden-sm"> <div class="summary-content-poster hidden-xs hidden-sm">
% if data['media_type'] == 'track': % if data['media_type'] == 'track':
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['parent_rating_key']}" target="_blank" title="View in Plex Web"> <a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['parent_rating_key']}" target="_blank" title="View on Plex Web">
% else: % else:
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['rating_key']}" target="_blank" title="View in Plex Web"> <a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['rating_key']}" target="_blank" title="View on Plex Web">
% endif % endif
% if data['media_type'] == 'episode': % if data['media_type'] == 'episode':
<div class="summary-poster-face-episode" style="background-image: url(pms_image_proxy?img=${data['thumb']}&width=500&height=280&fallback=art);"> <div class="summary-poster-face-episode" style="background-image: url(pms_image_proxy?img=${data['thumb']}&width=500&height=280&fallback=art);">

View File

@@ -42,7 +42,7 @@ DOCUMENTATION :: END
<td>${arrow.get(next_run_interval).format('HH:mm:ss')}</td> <td>${arrow.get(next_run_interval).format('HH:mm:ss')}</td>
<td>${arrow.get(sched_job.next_run_time).format('YYYY-MM-DD HH:mm:ss')}</td> <td>${arrow.get(sched_job.next_run_time).format('YYYY-MM-DD HH:mm:ss')}</td>
</tr> </tr>
% elif job in ('Check for active sessions', 'Check for recently added items') and plexpy.WS_CONNECTED: % elif job in ('Check for server response', 'Check for active sessions', 'Check for recently added items') and plexpy.WS_CONNECTED:
<tr> <tr>
<td>${job}</td> <td>${job}</td>
<td><i class="fa fa-sm fa-fw fa-check"></i> Websocket</td> <td><i class="fa fa-sm fa-fw fa-check"></i> Websocket</td>

View File

@@ -382,7 +382,7 @@ def initialize_scheduler():
schedule_job(libraries.refresh_libraries, 'Refresh libraries list', schedule_job(libraries.refresh_libraries, 'Refresh libraries list',
hours=library_hours, minutes=0, seconds=0) hours=library_hours, minutes=0, seconds=0)
schedule_job(activity_pinger.check_server_response, 'Check server response', schedule_job(activity_pinger.check_server_response, 'Check for server response',
hours=0, minutes=0, seconds=0) hours=0, minutes=0, seconds=0)
else: else:
@@ -404,7 +404,7 @@ def initialize_scheduler():
response_seconds = CONFIG.WEBSOCKET_CONNECTION_ATTEMPTS * CONFIG.WEBSOCKET_CONNECTION_TIMEOUT response_seconds = CONFIG.WEBSOCKET_CONNECTION_ATTEMPTS * CONFIG.WEBSOCKET_CONNECTION_TIMEOUT
response_seconds = 60 if response_seconds < 60 else response_seconds response_seconds = 60 if response_seconds < 60 else response_seconds
schedule_job(activity_pinger.check_server_response, 'Check server response', schedule_job(activity_pinger.check_server_response, 'Check for server response',
hours=0, minutes=0, seconds=response_seconds) hours=0, minutes=0, seconds=response_seconds)
# Start scheduler # Start scheduler

View File

@@ -54,7 +54,7 @@ class ActivityHandler(object):
def get_rating_key(self): def get_rating_key(self):
if self.is_valid_session(): if self.is_valid_session():
return int(self.timeline['ratingKey']) return self.timeline['ratingKey']
return None return None
@@ -65,6 +65,10 @@ class ActivityHandler(object):
if session_list: if session_list:
for session in session_list['sessions']: for session in session_list['sessions']:
if int(session['session_key']) == self.get_session_key(): if int(session['session_key']) == self.get_session_key():
# Live sessions don't have rating keys in sessions
# Get it from the websocket data
if not session['rating_key']:
session['rating_key'] = self.get_rating_key()
return session return session
return None return None
@@ -507,12 +511,12 @@ def on_created(rating_key, **kwargs):
if metadata: if metadata:
notify = True notify = True
now = int(time.time()) # now = int(time.time())
#
if helpers.cast_to_int(metadata['added_at']) < now - 86400: # Updated more than 24 hours ago # if helpers.cast_to_int(metadata['added_at']) < now - 86400: # Updated more than 24 hours ago
logger.debug(u"Tautulli TimelineHandler :: Library item %s added more than 24 hours ago. Not notifying." # logger.debug(u"Tautulli TimelineHandler :: Library item %s added more than 24 hours ago. Not notifying."
% str(rating_key)) # % str(rating_key))
notify = False # notify = False
data_factory = datafactory.DataFactory() data_factory = datafactory.DataFactory()
if 'child_keys' not in kwargs: if 'child_keys' not in kwargs:

View File

@@ -174,11 +174,11 @@ HW_ENCODERS = [
SCHEDULER_LIST = [ SCHEDULER_LIST = [
'Check GitHub for updates', 'Check GitHub for updates',
'Check for server response',
'Check for active sessions', 'Check for active sessions',
'Check for recently added items', 'Check for recently added items',
'Check for Plex updates', 'Check for Plex updates',
'Check for Plex remote access', 'Check for Plex remote access',
'Check server response',
'Refresh users list', 'Refresh users list',
'Refresh libraries list', 'Refresh libraries list',
'Refresh Plex server URLs', 'Refresh Plex server URLs',
@@ -279,15 +279,21 @@ NOTIFICATION_PARAMETERS = [
{ {
'category': 'Global', 'category': 'Global',
'parameters': [ 'parameters': [
{'name': 'Tautulli Version', 'type': 'str', 'value': 'plexpy_version', 'description': 'The current version of Tautulli.'}, {'name': 'Tautulli Version', 'type': 'str', 'value': 'tautulli_version', 'description': 'The current version of Tautulli.'},
{'name': 'Tautulli Branch', 'type': 'str', 'value': 'plexpy_branch', 'description': 'The current git branch of Tautulli.'}, {'name': 'Tautulli Remote', 'type': 'str', 'value': 'tautulli_remote', 'description': 'The current git remote of Tautulli.'},
{'name': 'Tautulli Commit', 'type': 'str', 'value': 'plexpy_commit', 'description': 'The current git commit hash of Tautulli.'}, {'name': 'Tautulli Branch', 'type': 'str', 'value': 'tautulli_branch', 'description': 'The current git branch of Tautulli.'},
{'name': 'Tautulli Commit', 'type': 'str', 'value': 'tautulli_commit', 'description': 'The current git commit hash of Tautulli.'},
{'name': 'Server Name', 'type': 'str', 'value': 'server_name', 'description': 'The name of your Plex Server.'}, {'name': 'Server Name', 'type': 'str', 'value': 'server_name', 'description': 'The name of your Plex Server.'},
{'name': 'Server Uptime', 'type': 'str', 'value': 'server_uptime', 'description': 'The uptime (in days, hours, mins, secs) of your Plex Server.'}, {'name': 'Server IP', 'type': 'str', 'value': 'server_ip', 'description': 'The connection IP address for your Plex Server.'},
{'name': 'Server Port', 'type': 'int', 'value': 'server_port', 'description': 'The connection port for your Plex Server.'},
{'name': 'Server URL', 'type': 'str', 'value': 'server_url', 'description': 'The connection URL for your Plex Server.'},
{'name': 'Server Platform', 'type': 'str', 'value': 'server_platform', 'description': 'The platform of your Plex Server.'},
{'name': 'Server Version', 'type': 'str', 'value': 'server_version', 'description': 'The current version of your Plex Server.'}, {'name': 'Server Version', 'type': 'str', 'value': 'server_version', 'description': 'The current version of your Plex Server.'},
{'name': 'Server ID', 'type': 'str', 'value': 'server_machine_id', 'description': 'The unique identifier for your Plex Server.'},
{'name': 'Action', 'type': 'str', 'value': 'action', 'description': 'The action that triggered the notification.'}, {'name': 'Action', 'type': 'str', 'value': 'action', 'description': 'The action that triggered the notification.'},
{'name': 'Datestamp', 'type': 'int', 'value': 'datestamp', 'description': 'The date (in date format) the notification was triggered.'}, {'name': 'Datestamp', 'type': 'str', 'value': 'datestamp', 'description': 'The date (in date format) when the notification was triggered.'},
{'name': 'Timestamp', 'type': 'int', 'value': 'timestamp', 'description': 'The time (in time format) the notification was triggered.'}, {'name': 'Timestamp', 'type': 'str', 'value': 'timestamp', 'description': 'The time (in time format) when the notification was triggered.'},
{'name': 'Unix Time', 'type': 'int', 'value': 'unixtime', 'description': 'The unix timestamp when the notification was triggered.'},
] ]
}, },
{ {
@@ -394,10 +400,12 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Last Viewed Date', 'type': 'str', 'value': 'last_viewed_date', 'description': 'The date (in date format) the item was last viewed on Plex.'}, {'name': 'Last Viewed Date', 'type': 'str', 'value': 'last_viewed_date', 'description': 'The date (in date format) the item was last viewed on Plex.'},
{'name': 'Studio', 'type': 'str', 'value': 'studio', 'description': 'The studio for the item.'}, {'name': 'Studio', 'type': 'str', 'value': 'studio', 'description': 'The studio for the item.'},
{'name': 'Content Rating', 'type': 'int', 'value': 'content_rating', 'description': 'The content rating for the item.', 'example': 'e.g. TV-MA, TV-PG, etc.'}, {'name': 'Content Rating', 'type': 'int', 'value': 'content_rating', 'description': 'The content rating for the item.', 'example': 'e.g. TV-MA, TV-PG, etc.'},
{'name': 'Director', 'type': 'str', 'value': 'directors', 'description': 'A list of directors for the item.'}, {'name': 'Directors', 'type': 'str', 'value': 'directors', 'description': 'A list of directors for the item.'},
{'name': 'Writer', 'type': 'str', 'value': 'writers', 'description': 'A list of writers for the item.'}, {'name': 'Writers', 'type': 'str', 'value': 'writers', 'description': 'A list of writers for the item.'},
{'name': 'Actor', 'type': 'str', 'value': 'actors', 'description': 'A list of actors for the item.'}, {'name': 'Actors', 'type': 'str', 'value': 'actors', 'description': 'A list of actors for the item.'},
{'name': 'Genre', 'type': 'str', 'value': 'genres', 'description': 'A list of genres for the item.'}, {'name': 'Genres', 'type': 'str', 'value': 'genres', 'description': 'A list of genres for the item.'},
{'name': 'Labels', 'type': 'str', 'value': 'labels', 'description': 'A list of labels for the item.'},
{'name': 'Collections', 'type': 'str', 'value': 'collections', 'description': 'A list of collections for the item.'},
{'name': 'Summary', 'type': 'str', 'value': 'summary', 'description': 'A short plot summary for the item.'}, {'name': 'Summary', 'type': 'str', 'value': 'summary', 'description': 'A short plot summary for the item.'},
{'name': 'Tagline', 'type': 'str', 'value': 'tagline', 'description': 'A tagline for the media item.'}, {'name': 'Tagline', 'type': 'str', 'value': 'tagline', 'description': 'A tagline for the media item.'},
{'name': 'Rating', 'type': 'float', 'value': 'rating', 'description': 'The rating (out of 10) for the item.'}, {'name': 'Rating', 'type': 'float', 'value': 'rating', 'description': 'The rating (out of 10) for the item.'},

View File

@@ -435,20 +435,6 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
time_format = plexpy.CONFIG.TIME_FORMAT.replace('Do','') time_format = plexpy.CONFIG.TIME_FORMAT.replace('Do','')
duration_format = plexpy.CONFIG.TIME_FORMAT.replace('Do','').replace('a','').replace('A','') duration_format = plexpy.CONFIG.TIME_FORMAT.replace('Do','').replace('a','').replace('A','')
# Get the server name
server_name = plexpy.CONFIG.PMS_NAME
# Get the server uptime
plex_tv = plextv.PlexTV()
server_times = plex_tv.get_server_times()
if server_times:
updated_at = server_times['updated_at']
server_uptime = helpers.human_duration(int(time.time() - helpers.cast_to_int(updated_at)))
else:
logger.error(u"Tautulli NotificationHandler :: Unable to retrieve server uptime.")
server_uptime = 'N/A'
# Get metadata for the item # Get metadata for the item
if session: if session:
rating_key = session['rating_key'] rating_key = session['rating_key']
@@ -660,15 +646,21 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
available_params = { available_params = {
# Global paramaters # Global paramaters
'plexpy_version': common.VERSION_NUMBER, 'tautulli_version': common.VERSION_NUMBER,
'plexpy_branch': plexpy.CONFIG.GIT_BRANCH, 'tautulli_remote': plexpy.CONFIG.GIT_REMOTE,
'plexpy_commit': plexpy.CURRENT_VERSION, 'tautulli_branch': plexpy.CONFIG.GIT_BRANCH,
'server_name': server_name, 'tautulli_commit': plexpy.CURRENT_VERSION,
'server_uptime': server_uptime, 'server_name': plexpy.CONFIG.PMS_NAME,
'server_version': server_times.get('version', ''), 'server_ip': plexpy.CONFIG.PMS_IP,
'server_port': plexpy.CONFIG.PMS_PORT,
'server_url': plexpy.CONFIG.PMS_URL,
'server_machine_id': plexpy.CONFIG.PMS_IDENTIFIER,
'server_platform': plexpy.CONFIG.PMS_PLATFORM,
'server_version': plexpy.CONFIG.PMS_VERSION,
'action': notify_action.lstrip('on_'), 'action': notify_action.lstrip('on_'),
'datestamp': arrow.now().format(date_format), 'datestamp': arrow.now().format(date_format),
'timestamp': arrow.now().format(time_format), 'timestamp': arrow.now().format(time_format),
'unixtime': int(time.time()),
# Stream parameters # Stream parameters
'streams': stream_count, 'streams': stream_count,
'user_streams': user_stream_count, 'user_streams': user_stream_count,
@@ -777,6 +769,8 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'writers': ', '.join(notify_params['writers']), 'writers': ', '.join(notify_params['writers']),
'actors': ', '.join(notify_params['actors']), 'actors': ', '.join(notify_params['actors']),
'genres': ', '.join(notify_params['genres']), 'genres': ', '.join(notify_params['genres']),
'labels': ', '.join(notify_params['labels']),
'collections': ', '.join(notify_params['collections']),
'summary': notify_params['summary'], 'summary': notify_params['summary'],
'tagline': notify_params['tagline'], 'tagline': notify_params['tagline'],
'rating': notify_params['rating'], 'rating': notify_params['rating'],
@@ -845,34 +839,26 @@ def build_server_notify_params(notify_action=None, **kwargs):
date_format = plexpy.CONFIG.DATE_FORMAT.replace('Do','') date_format = plexpy.CONFIG.DATE_FORMAT.replace('Do','')
time_format = plexpy.CONFIG.TIME_FORMAT.replace('Do','') time_format = plexpy.CONFIG.TIME_FORMAT.replace('Do','')
# Get the server name
server_name = plexpy.CONFIG.PMS_NAME
# Get the server uptime
plex_tv = plextv.PlexTV()
server_times = plex_tv.get_server_times()
pms_download_info = defaultdict(str, kwargs.pop('pms_download_info', {})) pms_download_info = defaultdict(str, kwargs.pop('pms_download_info', {}))
plexpy_download_info = defaultdict(str, kwargs.pop('plexpy_download_info', {})) plexpy_download_info = defaultdict(str, kwargs.pop('plexpy_download_info', {}))
if server_times:
updated_at = server_times['updated_at']
server_uptime = helpers.human_duration(int(time.time() - helpers.cast_to_int(updated_at)))
else:
logger.error(u"Tautulli NotificationHandler :: Unable to retrieve server uptime.")
server_uptime = 'N/A'
available_params = { available_params = {
# Global paramaters # Global paramaters
'plexpy_version': common.VERSION_NUMBER, 'tautulli_version': common.VERSION_NUMBER,
'plexpy_branch': plexpy.CONFIG.GIT_BRANCH, 'tautulli_remote': plexpy.CONFIG.GIT_REMOTE,
'plexpy_commit': plexpy.CURRENT_VERSION, 'tautulli_branch': plexpy.CONFIG.GIT_BRANCH,
'server_name': server_name, 'tautulli_commit': plexpy.CURRENT_VERSION,
'server_uptime': server_uptime, 'server_name': plexpy.CONFIG.PMS_NAME,
'server_version': server_times.get('version', ''), 'server_ip': plexpy.CONFIG.PMS_IP,
'server_port': plexpy.CONFIG.PMS_PORT,
'server_url': plexpy.CONFIG.PMS_URL,
'server_platform': plexpy.CONFIG.PMS_PLATFORM,
'server_version': plexpy.CONFIG.PMS_VERSION,
'server_machine_id': plexpy.CONFIG.PMS_IDENTIFIER,
'action': notify_action.lstrip('on_'), 'action': notify_action.lstrip('on_'),
'datestamp': arrow.now().format(date_format), 'datestamp': arrow.now().format(date_format),
'timestamp': arrow.now().format(time_format), 'timestamp': arrow.now().format(time_format),
'unixtime': int(time.time()),
# Plex Media Server update parameters # Plex Media Server update parameters
'update_version': pms_download_info['version'], 'update_version': pms_download_info['version'],
'update_url': pms_download_info['download_url'], 'update_url': pms_download_info['download_url'],
@@ -887,12 +873,12 @@ def build_server_notify_params(notify_action=None, **kwargs):
'update_changelog_added': pms_download_info['changelog_added'], 'update_changelog_added': pms_download_info['changelog_added'],
'update_changelog_fixed': pms_download_info['changelog_fixed'], 'update_changelog_fixed': pms_download_info['changelog_fixed'],
# Tautulli update parameters # Tautulli update parameters
'plexpy_update_version': plexpy_download_info['tag_name'], 'tautulli_update_version': plexpy_download_info['tag_name'],
'plexpy_update_tar': plexpy_download_info['tarball_url'], 'tautulli_update_tar': plexpy_download_info['tarball_url'],
'plexpy_update_zip': plexpy_download_info['zipball_url'], 'tautulli_update_zip': plexpy_download_info['zipball_url'],
'plexpy_update_commit': kwargs.pop('plexpy_update_commit', ''), 'tautulli_update_commit': kwargs.pop('plexpy_update_commit', ''),
'plexpy_update_behind': kwargs.pop('plexpy_update_behind', ''), 'tautulli_update_behind': kwargs.pop('plexpy_update_behind', ''),
'plexpy_update_changelog': plexpy_download_info['body'] 'tautulli_update_changelog': plexpy_download_info['body']
} }
return available_params return available_params

View File

@@ -588,6 +588,7 @@ class PmsConnect(object):
actors = [] actors = []
genres = [] genres = []
labels = [] labels = []
collections = []
if metadata_main.getElementsByTagName('Director'): if metadata_main.getElementsByTagName('Director'):
for director in metadata_main.getElementsByTagName('Director'): for director in metadata_main.getElementsByTagName('Director'):
@@ -609,6 +610,10 @@ class PmsConnect(object):
for label in metadata_main.getElementsByTagName('Label'): for label in metadata_main.getElementsByTagName('Label'):
labels.append(helpers.get_xml_attr(label, 'tag')) labels.append(helpers.get_xml_attr(label, 'tag'))
if metadata_main.getElementsByTagName('Collection'):
for collection in metadata_main.getElementsByTagName('Collection'):
collections.append(helpers.get_xml_attr(collection, 'tag'))
if metadata_type == 'movie': if metadata_type == 'movie':
metadata = {'media_type': metadata_type, metadata = {'media_type': metadata_type,
'section_id': section_id, 'section_id': section_id,
@@ -646,6 +651,7 @@ class PmsConnect(object):
'actors': actors, 'actors': actors,
'genres': genres, 'genres': genres,
'labels': labels, 'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(metadata_main, 'title') 'full_title': helpers.get_xml_attr(metadata_main, 'title')
} }
@@ -686,6 +692,7 @@ class PmsConnect(object):
'actors': actors, 'actors': actors,
'genres': genres, 'genres': genres,
'labels': labels, 'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(metadata_main, 'title') 'full_title': helpers.get_xml_attr(metadata_main, 'title')
} }
@@ -728,6 +735,7 @@ class PmsConnect(object):
'actors': show_details['actors'], 'actors': show_details['actors'],
'genres': show_details['genres'], 'genres': show_details['genres'],
'labels': show_details['labels'], 'labels': show_details['labels'],
'collections': show_details['collections'],
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'parentTitle'), 'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'parentTitle'),
helpers.get_xml_attr(metadata_main, 'title')) helpers.get_xml_attr(metadata_main, 'title'))
} }
@@ -771,6 +779,7 @@ class PmsConnect(object):
'actors': show_details['actors'], 'actors': show_details['actors'],
'genres': show_details['genres'], 'genres': show_details['genres'],
'labels': show_details['labels'], 'labels': show_details['labels'],
'collections': show_details['collections'],
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'grandparentTitle'), 'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
helpers.get_xml_attr(metadata_main, 'title')) helpers.get_xml_attr(metadata_main, 'title'))
} }
@@ -812,6 +821,7 @@ class PmsConnect(object):
'actors': actors, 'actors': actors,
'genres': genres, 'genres': genres,
'labels': labels, 'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(metadata_main, 'title') 'full_title': helpers.get_xml_attr(metadata_main, 'title')
} }
@@ -854,6 +864,7 @@ class PmsConnect(object):
'actors': actors, 'actors': actors,
'genres': genres, 'genres': genres,
'labels': labels, 'labels': labels,
'collections': collections,
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'parentTitle'), 'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'parentTitle'),
helpers.get_xml_attr(metadata_main, 'title')) helpers.get_xml_attr(metadata_main, 'title'))
} }
@@ -897,6 +908,7 @@ class PmsConnect(object):
'actors': actors, 'actors': actors,
'genres': album_details['genres'], 'genres': album_details['genres'],
'labels': album_details['labels'], 'labels': album_details['labels'],
'collections': album_details['collections'],
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'grandparentTitle'), 'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'grandparentTitle'),
helpers.get_xml_attr(metadata_main, 'title')) helpers.get_xml_attr(metadata_main, 'title'))
} }
@@ -938,6 +950,7 @@ class PmsConnect(object):
'actors': actors, 'actors': actors,
'genres': genres, 'genres': genres,
'labels': labels, 'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(metadata_main, 'title') 'full_title': helpers.get_xml_attr(metadata_main, 'title')
} }
@@ -980,6 +993,7 @@ class PmsConnect(object):
'actors': actors, 'actors': actors,
'genres': photo_album_details['genres'], 'genres': photo_album_details['genres'],
'labels': photo_album_details['labels'], 'labels': photo_album_details['labels'],
'collections': photo_album_details['collections'],
'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'parentTitle'), 'full_title': u'{} - {}'.format(helpers.get_xml_attr(metadata_main, 'parentTitle'),
helpers.get_xml_attr(metadata_main, 'title')) helpers.get_xml_attr(metadata_main, 'title'))
} }
@@ -1025,6 +1039,7 @@ class PmsConnect(object):
'actors': actors, 'actors': actors,
'genres': genres, 'genres': genres,
'labels': labels, 'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(metadata_main, 'title') 'full_title': helpers.get_xml_attr(metadata_main, 'title')
} }
@@ -1065,6 +1080,7 @@ class PmsConnect(object):
'actors': actors, 'actors': actors,
'genres': genres, 'genres': genres,
'labels': labels, 'labels': labels,
'collections': collections,
'full_title': helpers.get_xml_attr(metadata_main, 'title') 'full_title': helpers.get_xml_attr(metadata_main, 'title')
} }
@@ -1586,6 +1602,7 @@ class PmsConnect(object):
channel_stream = 1 channel_stream = 1
clip_media = session.getElementsByTagName('Media')[0] clip_media = session.getElementsByTagName('Media')[0]
clip_part = clip_media.getElementsByTagName('Part')[0]
audio_channels = helpers.get_xml_attr(clip_media, 'audioChannels') audio_channels = helpers.get_xml_attr(clip_media, 'audioChannels')
metadata_details = {'media_type': media_type, metadata_details = {'media_type': media_type,
'section_id': helpers.get_xml_attr(session, 'librarySectionID'), 'section_id': helpers.get_xml_attr(session, 'librarySectionID'),
@@ -1624,7 +1641,8 @@ class PmsConnect(object):
'genres': [], 'genres': [],
'labels': [], 'labels': [],
'full_title': helpers.get_xml_attr(session, 'title'), 'full_title': helpers.get_xml_attr(session, 'title'),
'container': helpers.get_xml_attr(clip_media, 'container'), 'container': helpers.get_xml_attr(clip_media, 'container') \
or helpers.get_xml_attr(clip_part, 'container'),
'height': helpers.get_xml_attr(clip_media, 'height'), 'height': helpers.get_xml_attr(clip_media, 'height'),
'width': helpers.get_xml_attr(clip_media, 'width'), 'width': helpers.get_xml_attr(clip_media, 'width'),
'video_codec': helpers.get_xml_attr(clip_media, 'videoCodec'), 'video_codec': helpers.get_xml_attr(clip_media, 'videoCodec'),
@@ -1633,7 +1651,8 @@ class PmsConnect(object):
'audio_channels': audio_channels, 'audio_channels': audio_channels,
'audio_channel_layout': common.AUDIO_CHANNELS.get(audio_channels, audio_channels), 'audio_channel_layout': common.AUDIO_CHANNELS.get(audio_channels, audio_channels),
'channel_icon': helpers.get_xml_attr(session, 'sourceIcon'), 'channel_icon': helpers.get_xml_attr(session, 'sourceIcon'),
'channel_title': helpers.get_xml_attr(session, 'sourceTitle') 'channel_title': helpers.get_xml_attr(session, 'sourceTitle'),
'live': int(helpers.get_xml_attr(session, 'live') == '1')
} }
else: else:
channel_stream = 0 channel_stream = 0
@@ -1699,6 +1718,21 @@ class PmsConnect(object):
source_subtitle_details = next((p for p in source_media_part_streams if p['id'] == subtitle_id), source_subtitle_details = next((p for p in source_media_part_streams if p['id'] == subtitle_id),
next((p for p in source_media_part_streams if p['type'] == '3'), source_subtitle_details)) next((p for p in source_media_part_streams if p['type'] == '3'), source_subtitle_details))
# Overrides for live sessions
if metadata_details.get('live') and transcode_decision == 'transcode':
stream_details['stream_container_decision'] = 'transcode'
stream_details['stream_container'] = transcode_details['transcode_container']
video_details['stream_video_decision'] = transcode_details['video_decision']
stream_details['stream_video_codec'] = transcode_details['transcode_video_codec']
stream_details['stream_video_resolution'] = metadata_details['video_resolution']
audio_details['stream_audio_decision'] = transcode_details['audio_decision']
stream_details['stream_audio_codec'] = transcode_details['transcode_audio_codec']
stream_details['stream_audio_channels'] = transcode_details['transcode_audio_channels']
stream_details['stream_audio_channel_layout'] = common.AUDIO_CHANNELS.get(
transcode_details['transcode_audio_channels'], transcode_details['transcode_audio_channels'])
# Get the quality profile # Get the quality profile
if media_type in ('movie', 'episode', 'clip') and 'stream_bitrate' in stream_details: if media_type in ('movie', 'episode', 'clip') and 'stream_bitrate' in stream_details:
stream_bitrate = helpers.cast_to_int(stream_details['stream_bitrate']) stream_bitrate = helpers.cast_to_int(stream_details['stream_bitrate'])

View File

@@ -1,2 +1,2 @@
PLEXPY_BRANCH = "beta" PLEXPY_BRANCH = "beta"
PLEXPY_RELEASE_VERSION = "v2.0.14-beta" PLEXPY_RELEASE_VERSION = "v2.0.15-beta"