Merge branch 'v2.5-export' into nightly

This commit is contained in:
JonnyWong16 2020-10-02 20:45:11 -07:00
commit 739c977cd7
No known key found for this signature in database
GPG key ID: B1F1F9807184697A
45 changed files with 6500 additions and 521 deletions

1
.gitignore vendored
View file

@ -17,6 +17,7 @@ version.lock
logs/* logs/*
backups/* backups/*
cache/* cache/*
exports/*
newsletters/* newsletters/*
*.mmdb *.mmdb
version.txt version.txt

162
API.md
View file

@ -115,6 +115,21 @@ Returns:
Delete and recreate the cache directory. Delete and recreate the cache directory.
### delete_export
Delete exports from Tautulli.
```
Required parameters:
export_id (int): The row id of the exported file to delete
Optional parameters:
delete_all (bool): 'true' to delete all exported files
Returns:
None
```
### delete_history ### delete_history
Delete history rows from Tautulli. Delete history rows from Tautulli.
@ -334,6 +349,21 @@ Download the Tautulli configuration file.
Download the Tautulli database file. Download the Tautulli database file.
### download_export
Download an exported metadata file
```
Required parameters:
export_id (int): The row id of the exported file to download
Optional parameters:
None
Returns:
download
```
### download_log ### download_log
Download the Tautulli log file. Download the Tautulli log file.
@ -377,6 +407,33 @@ Returns:
``` ```
### export_metadata
Export library or media metadata to a file
```
Required parameters:
section_id (int): The section id of the library items to export, OR
rating_key (int): The rating key of the media item to export
Optional parameters:
file_format (str): csv (default), json, or xml
metadata_level (int): The level of metadata to export (default 1)
media_info_level (int): The level of media info to export (default 1)
include_thumb (bool): True to export poster/cover images
include_art (bool): True to export background artwork images
custom_fields (str): Comma separated list of custom fields to export
in addition to the export level selected
library_export (str): collection or playlist for library export,
otherwise default to all library items
Returns:
json:
{"result": "success",
"message": "Metadata export has started."
}
```
### get_activity ### get_activity
Get the current activity on the PMS. Get the current activity on the PMS.
@ -654,6 +711,26 @@ Returns:
``` ```
### get_collections_table
Get the data on the Tautulli collections tables.
```
Required parameters:
section_id (str): The id of the Plex library section, OR
Optional parameters:
None
Returns:
json:
{"draw": 1,
"recordsTotal": 5,
"data":
[...]
}
```
### get_date_formats ### get_date_formats
Get the date and time formats used by Tautulli. Get the date and time formats used by Tautulli.
@ -672,6 +749,71 @@ Returns:
``` ```
### get_export_fields
Get a list of available custom export fields.
```
Required parameters:
media_type (str): The media type of the fields to return
Optional parameters:
sub_media_type (str): The child media type for
collections (movie, show, artist, album, photoalbum),
or playlists (video, audio, photo)
Returns:
json:
{"metadata_fields":
[{"field": "addedAt", "level": 1},
...
],
"media_info_fields":
[{"field": "media.aspectRatio", "level": 1},
...
]
}
```
### get_exports_table
Get the data on the Tautulli export tables.
```
Required parameters:
section_id (str): The id of the Plex library section, OR
rating_key (str): The rating key of the exported item
Optional parameters:
order_column (str): "added_at", "sort_title", "container", "bitrate", "video_codec",
"video_resolution", "video_framerate", "audio_codec", "audio_channels",
"file_size", "last_played", "play_count"
order_dir (str): "desc" or "asc"
start (int): Row to start from, 0
length (int): Number of items to return, 25
search (str): A string to search for, "Thrones"
Returns:
json:
{"draw": 1,
"recordsTotal": 10,
"recordsFiltered": 3,
"data":
[{"row_id": 2,
"timestamp": 1596484600,
"section_id": 1,
"rating_key": 270716,
"media_type": "movie",
"media_type_title": "Movie",
"filename": "Movie - Frozen II [270716].20200803125640.json",
"complete": 1
},
{...},
{...}
]
}
```
### get_geoip_lookup ### get_geoip_lookup
Get the geolocation info for an IP address. Get the geolocation info for an IP address.
@ -1557,6 +1699,26 @@ Returns:
``` ```
### get_playlists_table
Get the data on the Tautulli playlists tables.
```
Required parameters:
section_id (str): The id of the Plex library section, OR
Optional parameters:
None
Returns:
json:
{"draw": 1,
"recordsTotal": 5,
"data":
[...]
}
```
### get_plays_by_date ### get_plays_by_date
Get graph data by date. Get graph data by date.

View file

@ -15,6 +15,8 @@
<meta name="author" content=""> <meta name="author" content="">
<link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet"> <link href="${http_root}css/bootstrap3/bootstrap.css" rel="stylesheet">
<link href="${http_root}css/pnotify.custom.min.css" rel="stylesheet" /> <link href="${http_root}css/pnotify.custom.min.css" rel="stylesheet" />
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
<link href="${http_root}css/selectize.min.css" rel="stylesheet" />
<link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet"> <link href="${http_root}css/tautulli.css${cache_param}" rel="stylesheet">
<link href="${http_root}css/opensans.min.css" rel="stylesheet"> <link href="${http_root}css/opensans.min.css" rel="stylesheet">
<link href="${http_root}css/font-awesome.all.min.css" rel="stylesheet"> <link href="${http_root}css/font-awesome.all.min.css" rel="stylesheet">
@ -294,6 +296,7 @@ ${next.modalIncludes()}
<script src="${http_root}js/pnotify.custom.min.js"></script> <script src="${http_root}js/pnotify.custom.min.js"></script>
<script src="${http_root}js/platform.min.js"></script> <script src="${http_root}js/platform.min.js"></script>
<script src="${http_root}js/ipaddr.min.js"></script> <script src="${http_root}js/ipaddr.min.js"></script>
<script src="${http_root}js/selectize.min.js"></script>
<script src="${http_root}js/script.js${cache_param}"></script> <script src="${http_root}js/script.js${cache_param}"></script>
<script src="${http_root}js/jquery.tripleclick.min.js"></script> <script src="${http_root}js/jquery.tripleclick.min.js"></script>
<script src="${http_root}js/ajaxNotifications.js"></script> <script src="${http_root}js/ajaxNotifications.js"></script>

View file

@ -49,6 +49,10 @@ DOCUMENTATION :: END
<td>Cache Directory:</td> <td>Cache Directory:</td>
<td>${plexpy.CONFIG.CACHE_DIR}</td> <td>${plexpy.CONFIG.CACHE_DIR}</td>
</tr> </tr>
<tr>
<td>Export Directory:</td>
<td>${plexpy.CONFIG.EXPORT_DIR}</td>
</tr>
<tr> <tr>
<td>Newsletter Directory:</td> <td>Newsletter Directory:</td>
<td>${plexpy.CONFIG.NEWSLETTER_DIR}</td> <td>${plexpy.CONFIG.NEWSLETTER_DIR}</td>

View file

@ -71,7 +71,7 @@ ul.ColVis_collection {
list-style: none; list-style: none;
width: 150px; width: 150px;
padding: 8px 8px 4px 8px; padding: 8px 8px 4px 8px;
margin: 10px 0px 0px 0px; margin: 10px 0px 10px 0px;
background-color: #444; background-color: #444;
overflow: hidden; overflow: hidden;
z-index: 2002; z-index: 2002;

View file

@ -217,6 +217,10 @@ select.form-control:focus,
.selectize-dropdown .optgroup-header { .selectize-dropdown .optgroup-header {
font-weight: bold; font-weight: bold;
} }
.selectize-dropdown [data-selectable].option-disabled {
color: #aaa;
cursor: default;
}
select.form-control option { select.form-control option {
color: #555; color: #555;
background-color: #eee; background-color: #eee;
@ -1750,6 +1754,7 @@ a:hover .dashboard-recent-media-cover {
box-shadow: inset 0 0 0 2px #e9a049; box-shadow: inset 0 0 0 2px #e9a049;
opacity: 0; opacity: 0;
transition: opacity .2s; transition: opacity .2s;
z-index: 2;
} }
.summary-poster-face-overlay span { .summary-poster-face-overlay span {
display: block; display: block;
@ -1963,7 +1968,10 @@ a:hover .summary-poster-face-track .summary-poster-face-overlay span {
.item-children-instance { .item-children-instance {
list-style: none; list-style: none;
margin: 0; margin: 0;
overflow: hidden; overflow: auto;
}
.item-children-instance.max-height {
max-height: 875px;
} }
.item-children-instance li { .item-children-instance li {
float: left; float: left;
@ -2099,7 +2107,7 @@ a:hover .item-children-poster {
} }
.item-children-list-item-title { .item-children-list-item-title {
display: inline-block; display: inline-block;
width: calc(100% - 110px); /*width: calc(100% - 110px);*/
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
@ -2109,9 +2117,16 @@ a:hover .item-children-poster {
color: #777; color: #777;
text-align: right; text-align: right;
display: inline-block; display: inline-block;
width: 40px; width: 60px;
margin-right: 20px; margin-right: 20px;
} }
.nav-list {
list-style: none;
padding: 0;
}
.nav-list.nav-pills > li > a {
margin-bottom: 0;
}
#new_title h3 { #new_title h3 {
color: #E5A00D; color: #E5A00D;
font-size: 14px; font-size: 14px;
@ -2185,32 +2200,17 @@ li.advanced-setting {
.user-info-username { .user-info-username {
font-size: 24px; font-size: 24px;
color: #eee; color: #eee;
padding-top: 27px; padding-top: 15px;
padding-left: 105px; padding-left: 105px;
} }
.user-info-nav { .user-info-nav {
margin-top: 15px; margin-top: 15px;
} padding-left: 105px;
.user-info-nav > .active > a {
color: #cc7b19;
} }
.nav-tabs > .active > a:hover, .nav-tabs > .active > a:hover,
.nav-tabs > .active > a:focus { .nav-tabs > .active > a:focus {
color: #e9a049; color: #e9a049;
} }
.user-info-nav a:hover {
color: #e9a049;
text-decoration: none;
}
.user-info-nav ul {
list-style: none;
padding: 0;
}
.user-info-nav li {
float: left;
margin-left: 10px;
margin-right: 10px;
}
.user-overview-stats-wrapper { .user-overview-stats-wrapper {
} }
.user-overview-stats-wrapper ul { .user-overview-stats-wrapper ul {
@ -3485,6 +3485,9 @@ pre::-webkit-scrollbar-thumb {
.selectize-input input[type='text'] { .selectize-input input[type='text'] {
height: 20px; height: 20px;
} }
.selectize-input.disabled, .selectize-input.disabled * {
cursor: not-allowed !important;
}
.small-muted { .small-muted {
font-size: small; font-size: small;
color: #777; color: #777;
@ -3707,6 +3710,20 @@ a:hover .overlay-refresh-image {
a:hover .overlay-refresh-image:hover { a:hover .overlay-refresh-image:hover {
opacity: .9; opacity: .9;
} }
.smart-playlist-image {
float: left;
position: absolute;
top: 5px;
left: 5px;
background-color: #8e6191;
border-radius: 4px;
color: #fff;
font-size: 16px;
z-index: 1;
width: 32px;
padding: 5px;
text-align: center;
}
#ip_error, #isp_error { #ip_error, #isp_error {
color: #aaa; color: #aaa;
display: none; display: none;

View file

@ -0,0 +1,246 @@
<%doc>
USAGE DOCUMENTATION :: PLEASE LEAVE THIS AT THE TOP OF THIS FILE
For Mako templating syntax documentation please visit: http://docs.makotemplates.org/en/latest/
Filename: export_modal.html
Version: 0.1
Variable names: data [list]
data :: Usable parameters
== Global keys ==
DOCUMENTATION :: END
</%doc>
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title" id="info-modal-title">
${title}
</h4>
</div>
<div class="modal-body">
<form method="post" class="form" id="export_metadata_form">
<input type="hidden" id="export_section_id" name="export_section_id" value="${section_id or ''}" />
<input type="hidden" id="export_user_id" name="export_user_id" value="${user_id or ''}" />
<input type="hidden" id="export_rating_key" name="export_rating_key" value="${rating_key or ''}" />
<input type="hidden" id="export_media_type" name="export_media_type" value="${media_type or ''}" />
<input type="hidden" id="export_sub_media_type" name="export_sub_media_type" value="${sub_media_type or ''}" />
<input type="hidden" id="export_export_type" name="export_export_type" value="${export_type or ''}" />
<div class="form-group">
<label for="metadata_export_level_select">Metadata Export Level</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="metadata_export_level_select" name="metadata_export_level_select">
<option value="0">Level 0 - Custom</option>
<option value="1" selected>Level 1 - Basic Metadata</option>
<option value="2">Level 2 - Extended Metadata</option>
<option value="3">Level 3 - Advanced Metadata</option>
<option value="9">Level 9 - All Metadata</option>
</select>
</div>
</div>
<p class="help-block">Select the metadata export level. Higher levels include all fields from the lower levels.</p>
</div>
<div class="form-group">
<label for="export_custom_metadata_fields">Custom Metadata Fields</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="export_custom_metadata_fields" name="export_custom_metadata_fields" data-field_type="Metadata">
</div>
</div>
<p class="help-block">Add additional fields to the selected metadata export level.</p>
</div>
<div class="form-group">
<label for="media_info_export_level_select">Media Info Export Level</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="media_info_export_level_select" name="media_info_export_level_select">
<option value="0">Level 0 - Custom</option>
<option value="1" selected>Level 1 - Basic Media Info</option>
<option value="2">Level 2 - Extended Media Info</option>
<option value="3">Level 3 - Advanced Media Info</option>
<option value="9">Level 9 - All Media Info</option>
</select>
</div>
</div>
<p class="help-block">Select the media info export level. Higher levels include all fields from the lower levels.</p>
</div>
<div class="form-group">
<label for="export_custom_media_info_fields">Custom Media Info Fields</label>
<div class="row">
<div class="col-md-12">
<input type="text" class="form-control" id="export_custom_media_info_fields" name="export_custom_media_info_fields" data-field_type="Media Info">
</div>
</div>
<p class="help-block">Add additional fields to the selected media info export level.</p>
</div>
<div class="form-group">
<label for="export_file_format">File Format</label>
<div class="row">
<div class="col-md-12">
<select class="form-control" id="export_file_format" name="export_file_format">
% for format in file_formats:
<option value="${format}">${format.upper()}</option>
% endfor
</select>
</div>
</div>
<p class="help-block">Select the export file format.</p>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="export_include_thumb" name="export_include_thumb" value="1"> Export poster / cover images
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" id="export_include_art" name="export_include_art" value="1"> Export background artwork images
</label>
</div>
<p class="help-block">
Enable to export posters and covers or background artwork image files. Images will be saved to a folder alongside the data file.<br>
Warning: Exporting images may take a long time!<br>
Note: Only applies to movies, shows, seasons, artists, albums, collections, and playlists.
</p>
</form>
</div>
<div class="modal-footer">
<div>
<input type="button" class="btn btn-bright btn-ok" data-dismiss="modal" id="export_metadata" value="Export">
</div>
</div>
</div>
</div>
<script src="${http_root}js/selectize.plugin.disable-options.js"></script>
<script>
$('#export_metadata_form').submit(function(e) {
e.preventDefault();
})
var optgroups = (function () {
var optgroups = [];
for (var i = 0; i <= 9; i++) {
optgroups.push({$order: i+1, value: i});
}
return optgroups
})()
var $export_custom_fields = $('#export_custom_metadata_fields, #export_custom_media_info_fields').selectize({
plugins: {
'remove_button': {},
'disable_options': {
disableField: 'level'
}
},
maxItems: null,
valueField: 'field',
labelField: 'field',
sortField: 'field',
searchField: ['field'],
optgroupField: 'level',
optgroups: optgroups,
lockOptgroupOrder: true,
render: {
optgroup_header: function(data, escape) {
return '<div class="optgroup-header">' + escape(this.$input.data('field_type') + ' Level: ' + data.value) + '</div>';
},
option: function (item, escape) {
return '<div data-field="' + escape(item.field) + '" data-level="' + escape(item.level) + '">' + escape(item.field) +'</div>';
}
}
});
var export_custom_metadata_fields = $export_custom_fields[0].selectize;
var export_custom_media_info_fields = $export_custom_fields[1].selectize;
function setDisabledFields() {
var metadata_export_level = $('#metadata_export_level_select option:selected').val();
var media_info_export_level = $('#media_info_export_level_select option:selected').val();
export_custom_metadata_fields.setDisabledOptions([...Array(parseInt(metadata_export_level) + 1).keys()]);
export_custom_media_info_fields.setDisabledOptions([...Array(parseInt(media_info_export_level) + 1).keys()]);
}
$('#metadata_export_level_select, #media_info_export_level_select').on('change', setDisabledFields);
function getExportFields() {
$.ajax({
url: 'get_export_fields',
async: true,
data: {
media_type: $('#export_media_type').val(),
sub_media_type: $('#export_sub_media_type').val()
},
success: function (result) {
if (result) {
export_custom_metadata_fields.addOption(result.metadata_fields);
export_custom_media_info_fields.addOption(result.media_info_fields);
setDisabledFields();
}
}
})
}
getExportFields();
$('#export_file_format').on('change', function() {
if ($(this).val() === 'm3u8') {
$('#metadata_export_level_select').prop('disabled', true);
$('#media_info_export_level_select').prop('disabled', true);
$("#export_include_thumb").prop('disabled', true);
$("#export_include_art").prop('disabled', true);
export_custom_metadata_fields.disable();
export_custom_media_info_fields.disable();
} else {
$('#metadata_export_level_select').prop('disabled', false);
$('#media_info_export_level_select').prop('disabled', false);
$("#export_include_thumb").prop('disabled', false);
$("#export_include_art").prop('disabled', false);
export_custom_metadata_fields.enable();
export_custom_media_info_fields.enable();
}
})
$("#export_metadata").click(function() {
var section_id = $('#export_section_id').val();
var user_id = $('#export_user_id').val();
var rating_key = $('#export_rating_key').val();
var metadata_export_level = $('#metadata_export_level_select option:selected').val();
var media_info_export_level = $('#media_info_export_level_select option:selected').val();
var file_format = $('#export_file_format option:selected').val();
var include_thumb = $("#export_include_thumb").is(':checked') ? 1 : 0;
var include_art = $("#export_include_art").is(':checked') ? 1 : 0;
var custom_fields = [
$('#export_custom_metadata_fields').val(),
$('#export_custom_media_info_fields').val()
].filter(Boolean).join(',');
var export_type = $('#export_export_type').val()
$.ajax({
url: 'export_metadata',
data: {
section_id: section_id,
user_id: user_id,
rating_key: rating_key,
metadata_level: metadata_export_level,
media_info_level: media_info_export_level,
file_format: file_format,
include_thumb: include_thumb,
include_art: include_art,
custom_fields: custom_fields,
export_type: export_type
},
async: true,
success: function (data) {
if (data.result === 'success') {
$("a[href=#tabs-export]").click();
redrawExportTable();
showMsg('<i class="fa fa-check"></i> ' + data.message, false, true, 5000);
} else {
showMsg('<i class="fa fa-exclamation-circle"></i> ' + data.message, false, true, 5000, true);
}
}
});
});
</script>

View file

@ -40,7 +40,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class='table-card-back'> <div class="table-card-back">
<ul class="nav nav-pills" role="tablist" id="graph-tabs"> <ul class="nav nav-pills" role="tablist" id="graph-tabs">
<li role="presentation"><a href="#tabs-1" aria-controls="tabs-1" data-toggle="tab" role="tab">Plays by Period</a></li> <li role="presentation"><a href="#tabs-1" aria-controls="tabs-1" data-toggle="tab" role="tab">Plays by Period</a></li>
<li role="presentation"><a href="#tabs-2" aria-controls="tabs-2" data-toggle="tab" role="tab">Stream Info</a></li> <li role="presentation"><a href="#tabs-2" aria-controls="tabs-2" data-toggle="tab" role="tab">Stream Info</a></li>

View file

@ -41,7 +41,7 @@ DOCUMENTATION :: END
from plexpy import notifiers from plexpy import notifiers
from plexpy.common import MEDIA_TYPE_HEADERS, MEDIA_FLAGS_AUDIO, MEDIA_FLAGS_VIDEO from plexpy.common import MEDIA_TYPE_HEADERS, MEDIA_FLAGS_AUDIO, MEDIA_FLAGS_VIDEO
from plexpy.helpers import page, get_percent from plexpy.helpers import page, get_percent, cast_to_int
# Get audio codec file # Get audio codec file
def af(codec): def af(codec):
@ -84,8 +84,10 @@ DOCUMENTATION :: END
%> %>
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
% if data['media_type'] not in ('photo_album', 'photo', 'playlist'):
<% fallback = 'art-live-full' if data['live'] else None %> <% fallback = 'art-live-full' if data['live'] else None %>
<div class="art-face" style="background-image:url(${page('pms_image_proxy', data['art'], data['rating_key'], 1920, 1080, fallback=fallback)})"></div> <div class="art-face" style="background-image:url(${page('pms_image_proxy', data['art'], data['rating_key'], 1920, 1080, fallback=fallback)})"></div>
% endif
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':
<span class="overlay-refresh-image info-art" title="Refresh background image"><i class="fa fa-refresh refresh_pms_image"></i></span> <span class="overlay-refresh-image info-art" title="Refresh background image"><i class="fa fa-refresh refresh_pms_image"></i></span>
% endif % endif
@ -150,6 +152,29 @@ DOCUMENTATION :: END
<li><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></li> <li><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span> <span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="active metadata-xml">Track ${data['media_index']} - ${data['title']}</li> <li class="active metadata-xml">Track ${data['media_index']} - ${data['title']}</li>
% elif data['media_type'] == 'photo_album':
<li><a href="${page('library', data['section_id'])}">${data['library_name']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
% if data['parent_title']:
<li><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
% endif
<li class="active metadata-xml">${data['title']}</li>
% elif data['media_type'] in ('photo', 'clip'):
<li class="hidden-xs hidden-sm"><a href="${page('library', data['section_id'])}">${data['library_name']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
<li class="active metadata-xml">${data['title']}</li>
% elif data['media_type'] == 'playlist':
% if user_info.get('user_id'):
<li><a href="${page('user', user_info.get('user_id'))}">${user_info.get('friendly_name')}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
% elif data['section_id']:
<li><a href="${page('library', data['section_id'])}">${data['library_name']}</a></li>
<span class="breadcrumb-arrow"><i class="fa fa-chevron-right"></i></span>
% endif
<li class="active metadata-xml">${data['title']}</li>
% endif % endif
</ul> </ul>
</div> </div>
@ -158,10 +183,13 @@ DOCUMENTATION :: END
<div class="summary-content-title-wrapper"> <div class="summary-content-title-wrapper">
<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': <% legacy = '&legacy=1' if data['media_type'] in ('photo_album', 'photo', 'clip') else '' %>
<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"> % if data['media_type'] in ('track', 'photo'):
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['parent_rating_key']}${legacy}" target="_blank" title="View on Plex Web">
% elif data['media_type'] == 'playlist':
<a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/playlist?key=%2Fplaylists%2F${data['rating_key']}" target="_blank" title="View on Plex Web">
% elif not data['live']: % elif not data['live']:
<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"> <a href="${config['pms_web_url']}#!/server/${config['pms_identifier']}/details?key=%2Flibrary%2Fmetadata%2F${data['rating_key']}${legacy}" target="_blank" title="View on Plex Web">
% endif % endif
% if data['live']: % if data['live']:
<div class="summary-poster-face" style="background-image: url(${page('pms_image_proxy', data['grandparent_thumb'] or data['thumb'], data['rating_key'], 300, 450, fallback='poster-live')});"> <div class="summary-poster-face" style="background-image: url(${page('pms_image_proxy', data['grandparent_thumb'] or data['thumb'], data['rating_key'], 300, 450, fallback='poster-live')});">
@ -179,11 +207,14 @@ DOCUMENTATION :: END
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span> <span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
% endif % endif
% elif data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track': % elif data['media_type'] in ('artist', 'album', 'track', 'playlist', 'photo_album', 'photo', 'clip'):
<div class="summary-poster-face-track" style="background-image: url(${page('pms_image_proxy', data['thumb'], data['rating_key'], 500, 500, fallback='cover')});"> <div class="summary-poster-face-track" style="background-image: url(${page('pms_image_proxy', data['thumb'], data['rating_key'], 500, 500, fallback='cover')});">
<div class="summary-poster-face-overlay"> <div class="summary-poster-face-overlay">
<span></span> <span></span>
</div> </div>
% if data['media_type'] == 'playlist' and data['smart']:
<span class="smart-playlist-image" title="Smart Playlist"><i class="fa fa-cog"></i></span>
% endif
</div> </div>
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':
<span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span> <span class="overlay-refresh-image" title="Refresh image"><i class="fa fa-refresh refresh_pms_image"></i></span>
@ -214,7 +245,7 @@ DOCUMENTATION :: END
<h3 class="hidden-xs">S${data['parent_media_index']} &middot; E${data['media_index']}</h3> <h3 class="hidden-xs">S${data['parent_media_index']} &middot; E${data['media_index']}</h3>
% endif % endif
% endif % endif
% elif data['media_type'] in ('movie', 'show', 'artist', 'collection'): % elif data['media_type'] in ('movie', 'show', 'artist', 'collection', 'playlist', 'photo_album'):
<h1>&nbsp;</h1><h1>${data['title']}</h1> <h1>&nbsp;</h1><h1>${data['title']}</h1>
% elif data['media_type'] == 'season': % elif data['media_type'] == 'season':
<h1>&nbsp;</h1><h1><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></h1> <h1>&nbsp;</h1><h1><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></h1>
@ -230,26 +261,30 @@ DOCUMENTATION :: END
<h1><a href="${page('info', data['grandparent_rating_key'])}">${data['original_title'] or data['grandparent_title']}</a></h1> <h1><a href="${page('info', data['grandparent_rating_key'])}">${data['original_title'] or data['grandparent_title']}</a></h1>
<h2><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a> - ${data['title']}</h2> <h2><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a> - ${data['title']}</h2>
<h3 class="hidden-xs">T${data['media_index']}</h3> <h3 class="hidden-xs">T${data['media_index']}</h3>
% elif data['media_type'] in ('photo', 'clip'):
<h1><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></h1>
<h2>${data['title']}</h2>
% endif % endif
</div> </div>
</div> </div>
</div> </div>
<div class="summary-content-wrapper"> <div class="summary-content-wrapper">
<div class="col-md-9"> <div class="col-md-9">
% if data['media_type'] == 'movie' or data['live']: <%
<div class="summary-content-padding hidden-xs hidden-sm" style="height: 305px;"> padding_height = ''
% elif data['media_type'] in ('show', 'season', 'collection'): if data['media_type'] == 'movie' or data['live']:
<div class="summary-content-padding hidden-xs hidden-sm" style="height: 270px;"> padding_height = 'height: 305px;'
% elif data['media_type'] == 'episode': elif data['media_type'] in ('show', 'season', 'collection'):
<div class="summary-content-padding hidden-xs hidden-sm" style="height: 70px;"> padding_height = 'height: 270px;'
% elif data['media_type'] == 'artist' or data['media_type'] == 'album': elif data['media_type'] == 'episode':
<div class="summary-content-padding hidden-xs hidden-sm" style="height: 150px;"> padding_height = 'height: 70px;'
% elif data['media_type'] == 'track': elif data['media_type'] in ('artist', 'album', 'playlist', 'photo_album', 'photo'):
<div class="summary-content-padding hidden-xs hidden-sm" style="height: 180px;"> padding_height = 'height: 150px;'
% else: elif data['media_type'] in ('track', 'clip'):
<div class="summary-content-padding hidden-xs hidden-sm"> padding_height = 'height: 180px;'
% endif %>
% if data['media_type'] in ('movie', 'episode', 'track'): <div class="summary-content-padding hidden-xs hidden-sm" style="${padding_height}">
% if data['media_type'] in ('movie', 'episode', 'track', 'clip'):
<div class="summary-content-media-info-wrapper"> <div class="summary-content-media-info-wrapper">
% if data['media_type'] != 'track' and media_info['video_codec']: % if data['media_type'] != 'track' and media_info['video_codec']:
<img class="summary-content-media-flag" title="${media_info['video_codec']}" src="${http_root}images/media_flags/video_codec/${media_info['video_codec'] | vf}.png" /> <img class="summary-content-media-flag" title="${media_info['video_codec']}" src="${http_root}images/media_flags/video_codec/${media_info['video_codec'] | vf}.png" />
@ -296,6 +331,19 @@ DOCUMENTATION :: END
</div> </div>
% endif % endif
% endif % endif
<div class="summary-content-details-tag">
% if data['media_type'] in ('collection', 'playlist') and data['children_count']:
<%
if data['media_type'] == 'collection':
suffix = MEDIA_TYPE_HEADERS[data['sub_media_type']]
elif data['media_type'] == 'playlist':
suffix = MEDIA_TYPE_HEADERS[data['playlist_type']]
if data['children_count'] == 1:
suffix = suffix[:-1]
%>
Items <strong> ${data['children_count']} ${suffix} </strong>
% endif
</div>
<div class="summary-content-details-tag"> <div class="summary-content-details-tag">
% if data['directors']: % if data['directors']:
Directed by <strong> ${data['directors'][0]}</strong> Directed by <strong> ${data['directors'][0]}</strong>
@ -315,6 +363,8 @@ DOCUMENTATION :: END
Aired <strong> <span id="airdate">${data['originally_available_at']}</span></strong> Aired <strong> <span id="airdate">${data['originally_available_at']}</span></strong>
% elif data['media_type'] == 'album' or data['media_type'] == 'track': % elif data['media_type'] == 'album' or data['media_type'] == 'track':
Released <strong> ${data['year']}</strong> Released <strong> ${data['year']}</strong>
% elif data['media_type'] in ('photo', 'clip'):
Taken <strong> <span id="airdate">${data['originally_available_at']}</span></strong>
% elif data['media_type'] == 'collection': % elif data['media_type'] == 'collection':
Year <strong> ${data['min_year']} - ${data['max_year']}</strong> Year <strong> ${data['min_year']} - ${data['max_year']}</strong>
% elif data['year']: % elif data['year']:
@ -323,7 +373,8 @@ DOCUMENTATION :: END
</div> </div>
<div class="summary-content-details-tag"> <div class="summary-content-details-tag">
% if data['duration']: % if data['duration']:
Runtime <strong> <span id="runtime">${data['duration']}</span> mins</strong> <% sig = 'dhms' if cast_to_int(data['duration']) < 300000 else 'dhm' %>
Runtime <strong> <span id="runtime" data-sig="${sig}">${data['duration']}</span></strong>
% endif % endif
</div> </div>
<div class="summary-content-details-tag"> <div class="summary-content-details-tag">
@ -439,6 +490,17 @@ DOCUMENTATION :: END
<div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading track list...</div> <div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading track list...</div>
</div> </div>
</div> </div>
% elif data['media_type'] == 'photo_album':
<div class="col-md-12">
<div class="table-card-header">
<div class="header-bar">
<span>Photo List for <strong>${data['title']}</strong></span>
</div>
</div>
<div class="table-card-back">
<div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading photo list...</div>
</div>
</div>
% elif data['media_type'] == 'collection': % elif data['media_type'] == 'collection':
<div class="col-md-12"> <div class="col-md-12">
<div class="table-card-header"> <div class="table-card-header">
@ -447,13 +509,45 @@ DOCUMENTATION :: END
</div> </div>
</div> </div>
<div class="table-card-back"> <div class="table-card-back">
<div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading movies list...</div> <div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading collection items...</div>
</div> </div>
</div> </div>
<div id="collection-related-list-container" style="display: none;"> <div id="collection-related-list-container" style="display: none;">
</div> </div>
% elif data['media_type'] == 'playlist':
<div class="col-md-12">
<div class="table-card-header">
<div class="header-bar">
<span>${MEDIA_TYPE_HEADERS[data['playlist_type']]} List for <strong>${data['title']}</strong></span>
</div>
</div>
<div class="table-card-back">
<div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i>&nbsp; Loading playlist items...</div>
</div>
</div>
% endif % endif
% if data['media_type'] != 'collection': <%
history_type = data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track')
history_active = 'active' if history_type else ''
export_active = 'active' if not history_type else ''
%>
% if history_type:
<div class="col-md-12">
<div class="table-card-header">
<ul class="nav nav-list nav-pills" role="tablist">
<li class="${history_active}"><a href="#tabs-history" role="tab" data-toggle="tab">History</a></li>
% if _session['user_group'] == 'admin':
<li class="${export_active}"><a href="#tabs-export" role="tab" data-toggle="tab">Export</a></li>
% endif
</ul>
</div>
</div>
% endif
<div class="tab-content">
% if history_type:
<div role="tabpanel" class="tab-pane ${history_active}" id="tabs-history">
<div class="container-fluid">
<div class="row">
<div class="col-md-12"> <div class="col-md-12">
<div class="table-card-header"> <div class="table-card-header">
<div class="header-bar"> <div class="header-bar">
@ -514,7 +608,7 @@ DOCUMENTATION :: END
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-dark refresh-history-button" id="refresh-history-list"><i class="fa fa-refresh"></i> Refresh history</button> <button class="btn btn-dark refresh-history-button" id="refresh-history-list"><i class="fa fa-refresh"></i> Refresh history</button>
</div> </div>
<div class="btn-group colvis-button-bar"></div> <div class="btn-group colvis-button-bar" id="button-bar-history"></div>
</div> </div>
</div> </div>
<div class="table-card-back"> <div class="table-card-back">
@ -540,7 +634,61 @@ DOCUMENTATION :: END
</table> </table>
</div> </div>
</div> </div>
</div>
</div>
</div>
% endif % endif
% if not data['live'] and _session['user_group'] == 'admin':
<div role="tabpanel" class="tab-pane ${export_active}" id="tabs-export">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class="table-card-header">
<div class="header-bar">
<span>Metadata Exports for <strong>${data['title']}</strong></span>
</div>
<div class="button-bar">
<div class="btn-group">
<button class="btn btn-dark export-button" id="toggle-export-modal" data-toggle="modal" data-target="#export-modal"
data-section_id="${data['section_id']}" data-rating_key="${data['rating_key']}"
data-media_type="${data['media_type']}" data-sub_media_type="${data['sub_media_type'] or data['playlist_type'] or ''}">
<i class="fa fa-file-export"></i> Export metadata
</button>
</div>
<div class="btn-group">
<button class="btn btn-dark refresh-export-table-button" id="refresh-export-table">
<i class="fa fa-refresh"></i> Refresh exports
</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-export"></div>
</div>
</div>
<div class="table-card-back">
<table class="display export_table" id="export_table-RK-${data['rating_key']}" width="100%">
<thead>
<tr>
<th align="left" id="timestamp">Exported At</th>
<th align="left" id="media_type_title">Media Type</th>
<th align="left" id="rating_key">Rating Key</th>
<th align="left" id="filename">Filename</th>
<th align="left" id="file_format">File Format</th>
<th align="left" id="metadata_level">Metadata Level</th>
<th align="left" id="media_info_level">Media Info Level</th>
<th align="left" id="media_info_level">Custom Fields</th>
<th align="left" id="file_size">File Size</th>
<th align="left" id="complete">Download</th>
<th align="left" id="delete">Delete</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
% endif
</div>
</div> </div>
</div> </div>
</div> </div>
@ -629,6 +777,8 @@ DOCUMENTATION :: END
</div> </div>
</div> </div>
% endif % endif
<div id="export-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="export-modal">
</div>
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
@ -641,8 +791,10 @@ DOCUMENTATION :: END
% if metadata: % if metadata:
<% <%
data = defaultdict(None, **metadata) data = defaultdict(None, **metadata)
history_user_id = '' if _session['user_group'] == 'admin' else _session['user_id']
%> %>
<script src="${http_root}js/tables/history_table.js${cache_param}"></script> <script src="${http_root}js/tables/history_table.js${cache_param}"></script>
<script src="${http_root}js/tables/export_table.js${cache_param}"></script>
% if data['live']: % if data['live']:
<script> <script>
function get_history() { function get_history() {
@ -653,7 +805,7 @@ DOCUMENTATION :: END
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),
guid: "${data['guid']}", guid: "${data['guid']}",
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}" user_id: "${history_user_id}"
}; };
} }
} }
@ -669,7 +821,7 @@ DOCUMENTATION :: END
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),
grandparent_rating_key: "${data['rating_key']}", grandparent_rating_key: "${data['rating_key']}",
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}" user_id: "${history_user_id}"
}; };
} }
} }
@ -685,7 +837,7 @@ DOCUMENTATION :: END
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),
parent_rating_key: "${data['rating_key']}", parent_rating_key: "${data['rating_key']}",
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}" user_id: "${history_user_id}"
}; };
} }
} }
@ -701,22 +853,157 @@ DOCUMENTATION :: END
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),
rating_key: "${data['rating_key']}", rating_key: "${data['rating_key']}",
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}" user_id: "${history_user_id}"
}; };
} }
} }
} }
</script> </script>
% endif % endif
% if data['media_type'] != 'collection': % if data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track'):
<script> <script>
$(document).ready(function () { function loadHistoryTable() {
get_history(); get_history();
history_table = $('#history_table-RK-${data["rating_key"]}').DataTable(history_table_options); history_table = $('#history_table-RK-${data["rating_key"]}').DataTable(history_table_options);
var colvis = new $.fn.dataTable.ColVis(history_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 12] }); var colvis = new $.fn.dataTable.ColVis(history_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark', exclude: [0, 12] });
$(colvis.button()).appendTo('div.colvis-button-bar'); $(colvis.button()).appendTo('#button-bar-history');
clearSearchButton('history_table-RK-${data["rating_key"]}', history_table); clearSearchButton('history_table-RK-${data["rating_key"]}', history_table);
}
$(document).ready(function () {
loadHistoryTable();
});
$("#refresh-history-list").click(function () {
history_table.draw();
});
</script>
% endif
% if data['media_type'] in ('show', 'season', 'artist', 'album', 'photo_album', 'collection', 'playlist'):
<script>
$.ajax({
url: 'get_item_children',
type: 'GET',
async: true,
data: {
rating_key: "${data['rating_key']}",
media_type: "${data['media_type']}"
},
complete: function(xhr, status) {
$("#children-list").html(xhr.responseText);
}
});
</script>
% endif
% if data['media_type'] == 'collection':
<script>
$.ajax({
url: 'get_item_children_related',
type: 'GET',
async: true,
data: {
rating_key: "${data['rating_key']}",
title: "${data['title']}"
},
complete: function(xhr, status) {
$("#collection-related-list-container").html(xhr.responseText).show();
}
});
</script>
% endif
<script>
$(document).ready(function () {
// Javascript to enable link to tab
var hash = document.location.hash;
var prefix = "tab_";
if (hash) {
$('.nav-list a[href=' + hash.replace(prefix, "") + ']').tab('show').trigger('show.bs.tab');
}
// Change hash for page-reload
$('.nav-list a').on('shown.bs.tab', function (e) {
window.location.hash = e.target.hash.replace("#", "#" + prefix);
});
});
$('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
$.fn.dataTable.tables({ visible: true, api: true }).columns.adjust();
});
var airdate = $("#airdate")
var runtime = $("#runtime")
airdate.html(moment(airdate.text()).format('MMM DD, YYYY'));
runtime.html(humanDuration(runtime.text(), runtime.data('sig')));
$('div.art-face').animate({ opacity: 0.2 }, { duration: 1000 });
$('#channel-icon').popover({
selector: '[data-toggle=popover]',
html: true,
container: 'body',
trigger: 'hover',
placement: 'right',
template: '<div class="popover channel-thumbnail-popover" role="tooltip"><div class="arrow" style="top: 50%;"></div><div class="popover-content"></div></div>',
content: function () {
return '<div class="channel-thumbnail" style="background-image: url(' + $(this).data('img') + ');" />';
}
});
</script>
% if _session['user_group'] == 'admin':
<script>
$("#toggle-export-modal").click(function() {
$.ajax({
url: 'export_metadata_modal',
data: {
section_id: $(this).data('section_id'),
rating_key: $(this).data('rating_key'),
media_type: $(this).data('media_type'),
sub_media_type: $(this).data('sub_media_type')
},
cache: false,
async: true,
complete: function(xhr, status) {
$("#export-modal").html(xhr.responseText);
}
});
});
function loadExportTable() {
// Build export table
export_table_options.ajax = {
url: 'get_export_list',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
rating_key: "${data['rating_key']}"
};
}
};
export_table = $('#export_table-RK-${data["rating_key"]}').DataTable(export_table_options);
export_table.columns([2, 7]).visible(false);
var colvis = new $.fn.dataTable.ColVis(export_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-export');
clearSearchButton('export_table-RK-${data["rating_key"]}', export_table);
}
$('a[href="#tabs-export"]').on('shown.bs.tab', function() {
if (typeof(export_table) === 'undefined') {
loadExportTable();
}
});
$(document).ready(function () {
if (!($('#tabs-history').length)) {
loadExportTable();
}
});
$("#refresh-export-table").click(function () {
export_table.draw();
});
$('#row-edit-mode').on('click', function() { $('#row-edit-mode').on('click', function() {
$('#row-edit-mode-alert').fadeIn(200); $('#row-edit-mode-alert').fadeIn(200);
@ -753,11 +1040,6 @@ DOCUMENTATION :: END
}); });
} }
}); });
});
$("#refresh-history-list").click(function () {
history_table.draw();
});
// Send recently added notification // Send recently added notification
$('#send-recently-added-notification').on('click', function () { $('#send-recently-added-notification').on('click', function () {
@ -782,57 +1064,12 @@ DOCUMENTATION :: END
}); });
}); });
}); });
</script>
% endif
% if data['media_type'] in ('show', 'season', 'artist', 'album', 'collection'):
<script>
$.ajax({
url: 'get_item_children',
type: 'GET',
async: true,
data: { rating_key : "${data['rating_key']}" },
complete: function(xhr, status) {
$("#children-list").html(xhr.responseText);
}
});
</script>
% endif
% if data['media_type'] == 'collection':
<script>
$.ajax({
url: 'get_item_children_related',
type: 'GET',
async: true,
data: {
rating_key : "${data['rating_key']}",
title: "${data['title']}"
},
complete: function(xhr, status) {
$("#collection-related-list-container").html(xhr.responseText).show();
}
});
</script>
% endif
<script>
$('.metadata-xml').on('tripleclick', function () { $('.metadata-xml').on('tripleclick', function () {
openPlexXML("/library/metadata/${data['rating_key']}"); openPlexXML("/library/metadata/${data['rating_key']}");
}); });
$("#airdate").html(moment($("#airdate").text()).format('MMM DD, YYYY'));
$("#runtime").html(millisecondsToMinutes($("#runtime").text(), true));
$('div.art-face').animate({ opacity: 0.2 }, { duration: 1000 });
$('#channel-icon').popover({
selector: '[data-toggle=popover]',
html: true,
container: 'body',
trigger: 'hover',
placement: 'right',
template: '<div class="popover channel-thumbnail-popover" role="tooltip"><div class="arrow" style="top: 50%;"></div><div class="popover-content"></div></div>',
content: function () {
return '<div class="channel-thumbnail" style="background-image: url(' + $(this).data('img') + ');" />';
}
});
</script> </script>
% endif
% if data.get('poster_url'): % if data.get('poster_url'):
<script> <script>
$('#hosted-poster').popover({ $('#hosted-poster').popover({

View file

@ -28,14 +28,15 @@ DOCUMENTATION :: END
% if data != None: % if data != None:
<% <%
from plexpy.helpers import page from plexpy.helpers import cast_to_int, page
%> %>
% if data['children_count'] > 0: % if data['children_count'] > 0:
<div class="item-children-wrapper"> <div class="item-children-wrapper">
<ul class="item-children-instance list-unstyled"> <% max_height ='max-height' if data['children_type'] == 'photo' or media_type == 'playlist' else '' %>
<ul class="item-children-instance ${max_height} list-unstyled">
% for child in data['children_list']: % for child in data['children_list']:
% if child['rating_key']: % if child['rating_key']:
% if data['children_type'] == 'track': % if data['children_type'] in ('track', 'photo') or media_type == 'playlist':
<li class="item-children-list-item"> <li class="item-children-list-item">
% else: % else:
<li> <li>
@ -123,37 +124,109 @@ DOCUMENTATION :: END
</h3> </h3>
</div> </div>
% elif data['children_type'] == 'track': % elif data['children_type'] == 'track':
% if loop.index % 2 == 0: <% e = 'even' if loop.index % 2 == 0 else 'odd' %>
<div class="item-children-list-item-even"> <div class="item-children-list-item-${e}">
<span class="item-children-list-item-index">&nbsp;${child['media_index']}</span> <span class="item-children-list-item-index">${child['media_index']}</span>
<span class="item-children-list-item-title"><a href="${page('info', child['rating_key'])}" title="${child['title']}">${child['title']}</a> <span class="item-children-list-item-title">
<span class="media-type-tooltip" data-toggle="tooltip" title="Track"><i class="fa fa-music fa-fw"></i></span>
<a href="${page('info', child['rating_key'])}" title="${child['title']}">${child['title']}</a>
% if child['original_title']: % if child['original_title']:
<span class="text-muted"> - ${child['original_title']}</span> <span class="text-muted"> - ${child['original_title']}</span>
% endif % endif
</span> </span>
<span class="item-children-list-item-duration" id="item-children-list-item-duration-${loop.index + 1}"> <span class="item-children-list-item-duration" id="item-children-list-item-duration-${loop.index + 1}">
<script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("m:ss"));</script> <% f = 'h:mm:ss' if cast_to_int(child['duration']) >= 3600000 else 'm:ss' %>
<script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("${f}"));</script>
</span> </span>
</div> </div>
% elif data['children_type'] == 'photo':
<% e = 'even' if loop.index % 2 == 0 else 'odd' %>
<div class="item-children-list-item-${e}">
<span class="item-children-list-item-index">${loop.index + 1}</span>
<span class="item-children-list-item-title">
% if child['media_type'] == 'photo_album':
<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-camera fa-fw"></i></span>
% elif child['media_type'] == 'clip':
<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-video-camera fa-fw"></i></span>
% else: % else:
<div class="item-children-list-item-odd"> <span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-picture-o fa-fw"></i></span>
<span class="item-children-list-item-index">&nbsp;${child['media_index']}</span>
<span class="item-children-list-item-title"><a href="${page('info', child['rating_key'])}" title="${child['title']}">${child['title']}</a>
% if child['original_title']:
<span class="text-muted"> - ${child['original_title']}</span>
% endif % endif
<a href="${page('info', child['rating_key'])}" title="${child['title']}">${child['title']}</a>
</span> </span>
% if child['duration']:
<span class="item-children-list-item-duration" id="item-children-list-item-duration-${loop.index + 1}"> <span class="item-children-list-item-duration" id="item-children-list-item-duration-${loop.index + 1}">
<script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("m:ss"));</script> <% f = 'h:mm:ss' if cast_to_int(child['duration']) >= 3600000 else 'm:ss' %>
<script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("${f}"));</script>
</span> </span>
</div>
% endif % endif
</div>
% elif media_type == 'playlist':
<% e = 'even' if loop.index % 2 == 0 else 'odd' %>
<div class="item-children-list-item-${e}">
<span class="item-children-list-item-index">${loop.index + 1}</span>
<span class="item-children-list-item-title">
% if child['media_type'] == 'movie':
<span class="media-type-tooltip" data-toggle="tooltip" title="Movie"><i class="fa fa-film fa-fw"></i></span>
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
${child['title']}
</a>
<span class="text-muted"> (${child['year']})</span>
% elif child['media_type'] == 'episode':
<span class="media-type-tooltip" data-toggle="tooltip" title="Episode"><i class="fa fa-television fa-fw"></i></span>
<a href="${page('info', child['grandparent_rating_key'])}" title="${child['grandparent_title']}">
${child['grandparent_title']}
</a> -
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
${child['title']}
</a>
<span class="text-muted"> (<a class="no-highlight" href="${page('info', child['parent_rating_key'])}" title="${child['parent_title']}">S${child['parent_media_index']}</a> &middot; <a class="no-highlight" href="${page('info', child['rating_key'])}" title="${child['title']}">E${child['media_index']}</a>)</span>
% elif child['media_type'] == 'track':
<span class="media-type-tooltip" data-toggle="tooltip" title="Track"><i class="fa fa-music fa-fw"></i></span>
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
${child['title']}
</a> -
<a href="${page('info', child['grandparent_rating_key'])}" title="${child['grandparent_title']}">
${child['grandparent_title']}
</a>
<span class="text-muted"> (<a class="no-highlight" href="${page('info', child['parent_rating_key'])}" title="${child['parent_title']}">${child['parent_title']}</a>)</span>
% elif child['media_type'] == 'photo':
<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-picture-o fa-fw"></i></span>
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
${child['title']}
</a>
% if child['grandparent_title']:
- <a href="${page('info', child['grandparent_rating_key'])}" title="${child['grandparent_title']}">
${child['grandparent_title']}
</a>
% endif
<span class="text-muted"> (<a class="no-highlight" href="${page('info', child['parent_rating_key'])}" title="${child['parent_title']}">${child['parent_title']}</a>)</span>
% elif child['media_type'] == 'clip':
<span class="media-type-tooltip" data-toggle="tooltip" title="Video"><i class="fa fa-video-camera fa-fw"></i></span>
<a href="${page('info', child['rating_key'])}" title="${child['title']}">
${child['title']}
</a>
<span class="text-muted"> (<a class="no-highlight" href="${page('info', child['parent_rating_key'])}" title="${child['parent_title']}">${child['parent_title']}</a>)</span>
% endif
</span>
% if child['duration']:
<span class="item-children-list-item-duration" id="item-children-list-item-duration-${loop.index + 1}">
<% f = 'h:mm:ss' if cast_to_int(child['duration']) >= 3600000 else 'm:ss' %>
<script>$('#item-children-list-item-duration-${loop.index + 1}').text(moment.utc(${child['duration']}).format("${f}"));</script>
</span>
% endif
</div>
% endif % endif
</li> </li>
% endif % endif
% endfor % endfor
</ul> </ul>
</div> </div>
<script>
$('body').tooltip({
selector: '[data-toggle="tooltip"]',
container: 'body'
});
</script>
% endif % endif
% endif % endif

View file

@ -790,6 +790,9 @@ ColVis.prototype = {
oStyle.top = oPos.top+"px"; oStyle.top = oPos.top+"px";
oStyle.left = iDivX+"px"; oStyle.left = iDivX+"px";
var iDocWidth = $(document).width();
var iDocHeight = $(document).height();
document.body.appendChild( nBackground ); document.body.appendChild( nBackground );
document.body.appendChild( nHidden ); document.body.appendChild( nHidden );
document.body.appendChild( this.dom.catcher ); document.body.appendChild( this.dom.catcher );
@ -819,12 +822,17 @@ ColVis.prototype = {
var iDivWidth = $(nHidden).outerWidth(); var iDivWidth = $(nHidden).outerWidth();
var iDivHeight = $(nHidden).outerHeight(); var iDivHeight = $(nHidden).outerHeight();
var iDocWidth = $(document).width(); var iDivMarginTop = parseInt($(nHidden).css("marginTop"), 10);
var iDivMarginBottom = parseInt($(nHidden).css("marginBottom"), 10);
if ( iLeft + iDivWidth > iDocWidth ) if ( iLeft + iDivWidth > iDocWidth )
{ {
nHidden.style.left = (iDocWidth-iDivWidth)+"px"; nHidden.style.left = (iDocWidth-iDivWidth)+"px";
} }
if ( iDivY + iDivHeight > iDocHeight )
{
nHidden.style.top = (oPos.top - iDivHeight - iDivMarginTop - iDivMarginBottom)+"px";
}
} }
this.s.hidden = false; this.s.hidden = false;
@ -846,7 +854,8 @@ ColVis.prototype = {
this.s.hidden = true; this.s.hidden = true;
$(this.dom.collection).animate({"opacity": 0}, that.s.iOverlayFade, function (e) { $(this.dom.collection).animate({"opacity": 0}, that.s.iOverlayFade, function (e) {
this.style.display = "none"; // this.style.display = "none";
document.body.removeChild( this );
} ); } );
$(this.dom.background).animate({"opacity": 0}, that.s.iOverlayFade, function (e) { $(this.dom.background).animate({"opacity": 0}, that.s.iOverlayFade, function (e) {

View file

@ -330,25 +330,6 @@ function humanTime(seconds) {
} }
} }
function humanTimeClean(seconds) {
var text;
if (seconds >= 86400) {
text = Math.floor(moment.duration(seconds, 'seconds').asDays()) + ' days ' + Math.floor(moment.duration((
seconds % 86400), 'seconds').asHours()) + ' hrs ' + Math.floor(moment.duration(
((seconds % 86400) % 3600), 'seconds').asMinutes()) + ' mins';
return text;
} else if (seconds >= 3600) {
text = Math.floor(moment.duration((seconds % 86400), 'seconds').asHours()) + ' hrs ' + Math.floor(moment.duration(
((seconds % 86400) % 3600), 'seconds').asMinutes()) + ' mins';
return text;
} else if (seconds >= 60) {
text = Math.floor(moment.duration(((seconds % 86400) % 3600), 'seconds').asMinutes()) + ' mins';
return text;
} else {
text = '0';
return text;
}
}
String.prototype.toProperCase = function () { String.prototype.toProperCase = function () {
return this.replace(/\w\S*/g, function (txt) { return this.replace(/\w\S*/g, function (txt) {
return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();
@ -372,6 +353,57 @@ function millisecondsToMinutes(ms, roundToMinute) {
} }
} }
} }
function humanDuration(ms, sig='dhms', units='ms') {
var factors = {
d: 86400000,
h: 3600000,
m: 60000,
s: 1000,
ms: 1
}
ms = parseInt(ms);
var d, h, m, s;
if (ms > 0) {
ms = ms * factors[units];
h = ms % factors['d'];
d = Math.trunc(ms / factors['d']);
m = h % factors['h'];
h = Math.trunc(h / factors['h']);
s = m % factors['m'];
m = Math.trunc(m / factors['m']);
ms = s % factors['s'];
s = Math.trunc(s / factors['s']);
var hd_list = [];
if (sig >= 'd' && d > 0) {
d = (sig === 'd' && h >= 12) ? d + 1 : d;
hd_list.push(d.toString() + ' day' + ((d > 1) ? 's' : ''));
}
if (sig >= 'dh' && h > 0) {
h = (sig === 'dh' && m >= 30) ? h + 1 : h;
hd_list.push(h.toString() + ' hr' + ((h > 1) ? 's' : ''));
}
if (sig >= 'dhm' && m > 0) {
m = (sig === 'dhm' && s >= 30) ? m + 1 : m;
hd_list.push(m.toString() + ' min' + ((m > 1) ? 's' : ''));
}
if (sig >= 'dhms' && s > 0) {
hd_list.push(s.toString() + ' sec' + ((s > 1) ? 's' : ''));
}
return hd_list.join(' ')
} else {
return '0'
}
}
// Our countdown plugin takes a callback, a duration, and an optional message // Our countdown plugin takes a callback, a duration, and an optional message
$.fn.countdown = function (callback, duration, message) { $.fn.countdown = function (callback, duration, message) {
// If no message is provided, we use an empty string // If no message is provided, we use an empty string
@ -803,3 +835,16 @@ function user_page(user_id, user) {
return params; return params;
} }
MEDIA_TYPE_HEADERS = {
'movie': 'Movies',
'show': 'TV Shows',
'season': 'Seasons',
'episode': 'Episodes',
'artist': 'Artists',
'album': 'Albums',
'track': 'Tracks',
'video': 'Videos',
'audio': 'Tracks',
'photo': 'Photos'
}

View file

@ -0,0 +1,87 @@
/**
* Plugin: "disable_options" (selectize.js)
* Copyright (c) 2013 Mondo Robot & contributors
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
* file except in compliance with the License. You may obtain a copy of the License at:
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
* ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*
* @authors Jake Myers <jmyers0022@gmail.com>, Vaughn Draughon <vaughn@rocksolidwebdesign.com>
*/
Selectize.define('disable_options', function(options) {
var self = this;
options = $.extend({
'disableField': '',
'disableOptions': []
}, options);
self.refreshOptions = (function() {
var original = self.refreshOptions;
return function() {
original.apply(this, arguments);
$.each(options.disableOptions, function(index, option) {
self.$dropdown_content.find('[data-' + options.disableField + '="' + String(option) + '"]').addClass('option-disabled');
});
};
})();
self.onOptionSelect = (function() {
var original = self.onOptionSelect;
return function(e) {
var value, $target, $option;
if (e.preventDefault) {
e.preventDefault();
e.stopPropagation();
}
$target = $(e.currentTarget);
if ($target.hasClass('option-disabled')) {
return;
}
return original.apply(this, arguments);
};
})();
self.disabledOptions = function() {
return options.disableOptions;
}
self.setDisabledOptions = function( values ) {
options.disableOptions = values
}
self.disableOptions = function( values ) {
if ( ! ( values instanceof Array ) ) {
values = [ values ]
}
values.forEach( function( val ) {
if ( options.disableOptions.indexOf( val ) == -1 ) {
options.disableOptions.push( val )
}
} );
}
self.enableOptions = function( values ) {
if ( ! ( values instanceof Array ) ) {
values = [ values ]
}
values.forEach( function( val ) {
var remove = options.disableOptions.indexOf( val );
if ( remove + 1 ) {
options.disableOptions.splice( remove, 1 );
}
} );
}
});

View file

@ -0,0 +1,98 @@
collections_table_options = {
"destroy": true,
"language": {
"search": "Search: ",
"lengthMenu": "Show _MENU_ entries per page",
"info": "Showing _START_ to _END_ of _TOTAL_ export items",
"infoEmpty": "Showing 0 to 0 of 0 entries",
"infoFiltered": "<span class='hidden-md hidden-sm hidden-xs'>(filtered from _MAX_ total entries)</span>",
"emptyTable": "No data in table",
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
},
"pagingType": "full_numbers",
"stateSave": true,
"stateDuration": 0,
"processing": false,
"serverSide": true,
"pageLength": 25,
"order": [0, 'asc'],
"autoWidth": false,
"scrollX": true,
"columnDefs": [
{
"targets": [0],
"data": "titleSort",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html('<a href="' + page('info', rowData['ratingKey']) + '"><i class="fa fa-blank fa-fw"></i>' + rowData['title'] + '</a>');
}
},
"width": "50%",
"className": "no-wrap"
},
{
"targets": [1],
"data": "collectionMode",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var mode = '';
if (cellData === -1) {
mode = 'Library default';
} else if (cellData === 0) {
mode = 'Hide collection';
} else if (cellData === 1) {
mode = 'Hide items in this collection';
} else if (cellData === 2) {
mode = 'Show this collection and its items';
}
$(td).html(mode);
}
},
"width": "20%",
"className": "no-wrap"
},
{
"targets": [2],
"data": "collectionSort",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var sort = '';
if (cellData === 0) {
sort = 'Release date';
} else if (cellData === 1) {
sort = 'Alphabetical';
}
$(td).html(sort);
}
},
"width": "20%",
"className": "no-wrap"
},
{
"targets": [3],
"data": "childCount",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var type = MEDIA_TYPE_HEADERS[rowData['subtype']] || '';
if (rowData['childCount'] == 1) {
type = type.slice(0, -1);
}
$(td).html(cellData + ' ' + type);
}
},
"width": "10%",
"className": "no-wrap"
}
],
"drawCallback": function (settings) {
// Jump to top of page
//$('html,body').scrollTop(0);
$('#ajaxMsg').fadeOut();
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0);
},
"rowCallback": function (row, rowData, rowIndex) {
}
};

View file

@ -0,0 +1,220 @@
var date_format = 'YYYY-MM-DD';
var time_format = 'hh:mm a';
$.ajax({
url: 'get_date_formats',
type: 'GET',
success: function (data) {
date_format = data.date_format;
time_format = data.time_format;
}
});
export_table_options = {
"destroy": true,
"language": {
"search": "Search: ",
"lengthMenu": "Show _MENU_ entries per page",
"info": "Showing _START_ to _END_ of _TOTAL_ export items",
"infoEmpty": "Showing 0 to 0 of 0 entries",
"infoFiltered": "<span class='hidden-md hidden-sm hidden-xs'>(filtered from _MAX_ total entries)</span>",
"emptyTable": "No data in table",
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
},
"pagingType": "full_numbers",
"stateSave": true,
"stateDuration": 0,
"processing": false,
"serverSide": true,
"pageLength": 25,
"order": [0, 'desc'],
"autoWidth": false,
"scrollX": true,
"columnDefs": [
{
"targets": [0],
"data": "timestamp",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(moment(cellData, "X").format(date_format + ' ' + time_format));
}
},
"width": "8%",
"className": "no-wrap"
},
{
"targets": [1],
"data": "media_type_title",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData);
}
},
"width": "7%",
"className": "no-wrap"
},
{
"targets": [2],
"data": "rating_key",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null) {
$(td).html('<a href="' + page('info', rowData['rating_key']) + '">' + cellData + '</a>');
}
},
"width": "6%",
"className": "no-wrap"
},
{
"targets": [3],
"data": "filename",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
if (rowData['complete'] === 1 && rowData['exists']) {
$(td).html('<a href="view_export?export_id=' + rowData['export_id'] + '" target="_blank">' + cellData + '</a>');
} else {
$(td).html(cellData);
}
}
},
"width": "40%",
"className": "no-wrap"
},
{
"targets": [4],
"data": "file_format",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var images = '';
if (rowData['include_thumb'] || rowData['include_art']) {
images = ' + images';
}
$(td).html(cellData + images);
}
},
"width": "7%",
"className": "no-wrap"
},
{
"targets": [5],
"data": "metadata_level",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null) {
$(td).html(cellData);
}
},
"width": "6%",
"className": "no-wrap"
},
{
"targets": [6],
"data": "media_info_level",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null) {
$(td).html(cellData);
}
},
"width": "6%",
"className": "no-wrap"
},
{
"targets": [7],
"data": "custom_fields",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(cellData.replace(/,/g, ', '));
}
},
"width": "6%",
"className": "datatable-wrap"
},
{
"targets": [8],
"data": "file_size",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '' && cellData !== null) {
$(td).html(humanFileSize(cellData));
}
},
"width": "6%",
"className": "no-wrap"
},
{
"targets": [9],
"data": "complete",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData === 1 && rowData['exists']) {
$(td).html('<button class="btn btn-xs btn-success pull-left" data-id="' + rowData['export_id'] + '"><i class="fa fa-file-download fa-fw"></i> Download</button>');
} else if (cellData === 0) {
$(td).html('<span class="btn btn-xs btn-dark pull-left export-processing" data-id="' + rowData['export_id'] + '" disabled><i class="fa fa-spinner fa-spin fa-fw"></i> Processing</span>');
} else if (cellData === -1) {
$(td).html('<span class="btn btn-xs btn-dark pull-left" data-id="' + rowData['export_id'] + '" disabled><i class="fa fa-exclamation-circle fa-fw"></i> Failed</span>');
} else {
$(td).html('<span class="btn btn-xs btn-dark pull-left" data-id="' + rowData['export_id'] + '" disabled><i class="fa fa-question-circle fa-fw"></i> Not Found</span>');
}
},
"width": "7%",
"className": "export_download"
},
{
"targets": [10],
"data": null,
"createdCell": function (td, cellData, rowData, row, col) {
if (rowData['complete'] !== 0) {
$(td).html('<button class="btn btn-xs btn-danger pull-left" data-id="' + rowData['export_id'] + '"><i class="fa fa-trash-o fa-fw"></i> Delete</button>');
} else {
$(td).html('<span class="btn btn-xs btn-danger pull-left" data-id="' + rowData['export_id'] + '" disabled><i class="fa fa-trash-o fa-fw"></i> Delete</span>');
}
},
"width": "7%",
"className": "export_delete"
}
],
"drawCallback": function (settings) {
// Jump to top of page
//$('html,body').scrollTop(0);
$('#ajaxMsg').fadeOut();
if (export_processing_timer) {
clearTimeout(export_processing_timer);
}
if ($('.export-processing').length) {
export_processing_timer = setTimeout(redrawExportTable.bind(null, false), 2000);
}
},
"preDrawCallback": function(settings) {
if (!export_processing_timer) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0)
}
},
"rowCallback": function (row, rowData, rowIndex) {
if (rowData['complete'] === 0) {
$(row).addClass('current-activity-row');
}
}
};
$('.export_table').on('click', '> tbody > tr > td.export_download > button', function (e) {
var tr = $(this).closest('tr');
var row = export_table.row(tr);
var rowData = row.data();
e.preventDefault();
window.location.href = 'download_export?export_id=' + rowData['export_id'];
});
$('.export_table').on('click', '> tbody > tr > td.export_delete > button', function (e) {
var tr = $(this).closest('tr');
var row = export_table.row(tr);
var rowData = row.data();
var msg = 'Are you sure you want to delete the following export?<br /><br /><strong>' + rowData['filename'] + '</strong>';
var url = 'delete_export?export_id=' + rowData['export_id'];
confirmAjaxCall(url, msg, null, null, redrawExportTable);
});
function redrawExportTable(paging) {
export_table.draw(paging);
}
var export_processing_timer;

View file

@ -192,7 +192,7 @@ libraries_list_table_options = {
"data": "duration", "data": "duration",
"createdCell": function (td, cellData, rowData, row, col) { "createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') { if (cellData !== null && cellData !== '') {
$(td).html(humanTimeClean(cellData)); $(td).html(humanDuration(cellData, 'dhm', 's'));
} }
}, },
"searchable": false, "searchable": false,

View file

@ -107,15 +107,15 @@ media_info_table_options = {
} else if (rowData['media_type'] === 'photo_album') { } else if (rowData['media_type'] === 'photo_album') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Photo Album"><i class="fa fa-camera fa-fw"></i></span>'; media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Photo Album"><i class="fa fa-camera fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['rating_key'], 300, 450, null, null, null, 'poster') + '" data-height="120" data-width="80">' + rowData['title'] + '</span>'; thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['rating_key'], 300, 450, null, null, null, 'poster') + '" data-height="120" data-width="80">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></div>'); $(td).html('<div class="history-title"><a href="' + page('info', rowData['rating_key']) + '"><div style="float: left;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'photo') { } else if (rowData['media_type'] === 'photo') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-picture-o fa-fw"></i></span>'; media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-picture-o fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['rating_key'], 300, 450, null, null, null, 'poster') + '" data-height="120" data-width="80">' + rowData['title'] + '</span>'; thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['rating_key'], 300, 450, null, null, null, 'poster') + '" data-height="120" data-width="80">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></div>'); $(td).html('<div class="history-title"><a href="' + page('info', rowData['rating_key']) + '"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else if (rowData['media_type'] === 'clip') { } else if (rowData['media_type'] === 'clip') {
media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Video"><i class="fa fa-video-camera fa-fw"></i></span>'; media_type = '<span class="media-type-tooltip" data-toggle="tooltip" title="Video"><i class="fa fa-video-camera fa-fw"></i></span>';
thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['rating_key'], 500, 280, null, null, null, 'art') + '" data-height="80" data-width="140">' + rowData['title'] + '</span>'; thumb_popover = '<span class="thumb-tooltip" data-toggle="popover" data-img="' + page('pms_image_proxy', rowData['thumb'], rowData['rating_key'], 500, 280, null, null, null, 'art') + '" data-height="80" data-width="140">' + rowData['title'] + '</span>';
$(td).html('<div class="history-title"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></div>'); $(td).html('<div class="history-title"><a href="' + page('info', rowData['rating_key']) + '"><div style="float: left; padding-left: 15px;">' + media_type + '&nbsp;' + thumb_popover + '</div></a></div>');
} else { } else {
$(td).html(cellData); $(td).html(cellData);
} }

View file

@ -0,0 +1,88 @@
playlists_table_options = {
"destroy": true,
"language": {
"search": "Search: ",
"lengthMenu": "Show _MENU_ entries per page",
"info": "Showing _START_ to _END_ of _TOTAL_ export items",
"infoEmpty": "Showing 0 to 0 of 0 entries",
"infoFiltered": "<span class='hidden-md hidden-sm hidden-xs'>(filtered from _MAX_ total entries)</span>",
"emptyTable": "No data in table",
"loadingRecords": '<i class="fa fa-refresh fa-spin"></i> Loading items...</div>'
},
"pagingType": "full_numbers",
"stateSave": true,
"stateDuration": 0,
"processing": false,
"serverSide": true,
"pageLength": 25,
"order": [0, 'asc'],
"autoWidth": false,
"scrollX": true,
"columnDefs": [
{
"targets": [0],
"data": "title",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var smart = '<i class="fa fa-blank fa-fw"></i>';
if (rowData['smart']) {
smart = '<span class="media-type-tooltip" data-toggle="tooltip" title="Smart Playlist"><i class="fa fa-cog fa-fw"></i></span>&nbsp;'
}
var breadcrumb = '';
if (rowData['userID']) {
breadcrumb = '&user_id=' + rowData['userID'];
} else if (rowData['librarySectionID']) {
breadcrumb = '&section_id=' + rowData['librarySectionID'];
}
$(td).html('<a href="' + page('info', rowData['ratingKey']) + breadcrumb +'">' + smart + cellData + '</a>');
}
},
"width": "60%",
"className": "no-wrap"
},
{
"targets": [1],
"data": "leafCount",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
var type = MEDIA_TYPE_HEADERS[rowData['playlistType']] || '';
if (rowData['leafCount'] === 1) {
type = type.slice(0, -1);
}
$(td).html(cellData + ' ' + type);
}
},
"width": "20%",
"className": "no-wrap"
},
{
"targets": [2],
"data": "duration",
"createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== '') {
$(td).html(humanDuration(cellData, 'dhm'));
}
},
"width": "20%",
"className": "no-wrap"
}
],
"drawCallback": function (settings) {
// Jump to top of page
//$('html,body').scrollTop(0);
$('#ajaxMsg').fadeOut();
// Create the tooltips.
$('body').tooltip({
selector: '[data-toggle="tooltip"]',
container: 'body'
});
},
"preDrawCallback": function(settings) {
var msg = "<i class='fa fa-refresh fa-spin'></i>&nbsp; Fetching rows...";
showMsg(msg, false, false, 0);
$('[data-toggle="tooltip"]').tooltip('destroy');
},
"rowCallback": function (row, rowData, rowIndex) {
}
};

View file

@ -212,7 +212,7 @@ users_list_table_options = {
"data": "duration", "data": "duration",
"createdCell": function (td, cellData, rowData, row, col) { "createdCell": function (td, cellData, rowData, row, col) {
if (cellData !== null && cellData !== '') { if (cellData !== null && cellData !== '') {
$(td).html(humanTimeClean(cellData)); $(td).html(humanDuration(cellData, 'dhm', 's'));
} }
}, },
"searchable": false, "searchable": false,

View file

@ -87,12 +87,17 @@ DOCUMENTATION :: END
% endif % endif
</div> </div>
<div class="user-info-nav"> <div class="user-info-nav">
<ul class="user-info-nav" role="tablist"> <ul class="nav nav-list nav-pills" role="tablist">
<li class="active"><a href="#tabs-profile" role="tab" data-toggle="tab">Profile</a></li> <li class="active"><a href="#tabs-profile" role="tab" data-toggle="tab">Profile</a></li>
<li><a id="history-tab-btn" href="#tabs-history" role="tab" data-toggle="tab">History</a></li> <li><a href="#tabs-history" role="tab" data-toggle="tab">History</a></li>
% if _session['user_group'] == 'admin':
% if data['section_id'] != LIVE_TV_SECTION_ID: % if data['section_id'] != LIVE_TV_SECTION_ID:
<li><a id="media-info-tab-btn" href="#tabs-mediainfo" role="tab" data-toggle="tab">Media Info</a></li> % if _session['user_group'] == 'admin':
<li><a href="#tabs-mediainfo" role="tab" data-toggle="tab">Media Info</a></li>
% endif
<li><a href="#tabs-collections" role="tab" data-toggle="tab">Collections</a></li>
<li><a href="#tabs-playlists" role="tab" data-toggle="tab">Playlists</a></li>
% if _session['user_group'] == 'admin':
<li><a href="#tabs-export" role="tab" data-toggle="tab">Export</a></li>
% endif % endif
% endif % endif
</ul> </ul>
@ -242,23 +247,22 @@ DOCUMENTATION :: END
</div> </div>
</div> </div>
</div> </div>
% if _session['user_group'] == 'admin':
<div role="tabpanel" class="tab-pane" id="tabs-mediainfo"> <div role="tabpanel" class="tab-pane" id="tabs-mediainfo">
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
% if config['get_file_sizes'] and data['section_id'] in config['get_file_sizes_hold']['section_ids']: % if config['get_file_sizes'] and data['section_id'] in config['get_file_sizes_hold']['section_ids']:
<div id="get_file_sizes_message" style="text-align: center; margin-top: 20px;"> <div id="get_file_sizes_message" style="text-align: center; margin-top: 20px;">
% else:
<div id="get_file_sizes_message" style="text-align: center; margin-top: 20px; display: none;">
% endif
<i class="fa fa-refresh fa-spin"></i>&nbsp; Tautulli is calculating the file sizes for the library's media info. This could take a few minutes depending on the size of your library. <i class="fa fa-refresh fa-spin"></i>&nbsp; Tautulli is calculating the file sizes for the library's media info. This could take a few minutes depending on the size of your library.
<br /> <br />
You may leave this page and check back later. You may leave this page and check back later.
</div> </div>
% endif
<div class='table-card-header'> <div class='table-card-header'>
<div class="header-bar"> <div class="header-bar">
<span> <span>
<i class="fa fa-history"></i> Media Info for <strong> <i class="fa fa-info-circle"></i> Media Info for <strong>
<span class="set-username">${data['section_name']}</span> <span class="set-username">${data['section_name']}</span>
</strong> </strong>
</span> </span>
@ -305,6 +309,155 @@ DOCUMENTATION :: END
</div> </div>
</div> </div>
</div> </div>
% endif
<div role="tabpanel" class="tab-pane" id="tabs-collections">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class='table-card-header'>
<div class="header-bar">
<span>
<i class="fa fa-folder-open"></i> Collections for <strong>
<span class="set-username">${data['section_name']}</span>
</strong>
</span>
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
<div class="btn-group">
<button class="btn btn-dark export-button" id="toggle-export-modal" data-toggle="modal" data-target="#export-modal"
data-section_id="${data['section_id']}" data-media_type="collection" data-sub_media_type="${data['section_type']}"
data-export_type="collection">
<i class="fa fa-file-export"></i> Export collections
</button>
</div>
% endif
<div class="btn-group">
<button class="btn btn-dark refresh-collections-table-button" id="refresh-collections-table">
<i class="fa fa-refresh"></i> Refresh collections
</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-collections"></div>
</div>
</div>
<div class="table-card-back">
<table class="display collections_table" id="collections_table-SID-${data['section_id']}" width="100%">
<thead>
<tr>
<th align="left" id="collectionTitle">Collection Title</th>
<th align="left" id="collectionMode">Collection Mode</th>
<th align="left" id="collectionSort">Collection Sort</th>
<th align="left" id="collectionItems">Collection Items</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div role="tabpanel" class="tab-pane" id="tabs-playlists">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class='table-card-header'>
<div class="header-bar">
<span>
<i class="fa fa-list-alt"></i> Playlists for <strong>
<span class="set-username">${data['section_name']}</span>
</strong>
</span>
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
<% playlist_sub_media_type = {'movie': 'video', 'show': 'video', 'artist': 'audio', 'photo': 'photo'} %>
<div class="btn-group">
<button class="btn btn-dark export-button" id="toggle-export-modal" data-toggle="modal" data-target="#export-modal"
data-section_id="${data['section_id']}" data-media_type="playlist" data-sub_media_type="${playlist_sub_media_type.get(data['section_type'])}"
data-export_type="playlist">
<i class="fa fa-file-export"></i> Export playlists
</button>
</div>
% endif
<div class="btn-group">
<button class="btn btn-dark refresh-playlists-table-button" id="refresh-playlists-table">
<i class="fa fa-refresh"></i> Refresh playlists
</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-playlists"></div>
</div>
</div>
<div class="table-card-back">
<table class="display playlists_table" id="playlists_table-SID-${data['section_id']}" width="100%">
<thead>
<tr>
<th align="left" id="playlistTitle">Playlist Title</th>
<th align="left" id="playlistLeafCount">Playlist Items</th>
<th align="left" id="playlistDuration">Playlist Duration</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
% if _session['user_group'] == 'admin':
<div role="tabpanel" class="tab-pane" id="tabs-export">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class='table-card-header'>
<div class="header-bar">
<span>
<i class="fa fa-file-export"></i> Metadata Exports for <strong>
<span class="set-username">${data['section_name']}</span>
</strong>
</span>
</div>
<div class="button-bar">
<div class="btn-group">
<button class="btn btn-dark export-button" id="toggle-export-modal" data-toggle="modal" data-target="#export-modal"
data-section_id="${data['section_id']}" data-media_type="${'photoalbum' if data['section_type'] == 'photo' else data['section_type']}"
data-export_type="all">
<i class="fa fa-file-export"></i> Export metadata
</button>
</div>
<div class="btn-group">
<button class="btn btn-dark refresh-export-table-button" id="refresh-export-table">
<i class="fa fa-refresh"></i> Refresh exports
</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-export"></div>
</div>
</div>
<div class="table-card-back">
<table class="display export_table" id="export_table-SID-${data['section_id']}" width="100%">
<thead>
<tr>
<th align="left" id="timestamp">Exported At</th>
<th align="left" id="media_type_title">Media Type</th>
<th align="left" id="rating_key">Rating Key</th>
<th align="left" id="filename">Filename</th>
<th align="left" id="file_format">File Format</th>
<th align="left" id="metadata_level">Metadata Level</th>
<th align="left" id="media_info_level">Media Info Level</th>
<th align="left" id="media_info_level">Custom Fields</th>
<th align="left" id="file_size">File Size</th>
<th align="left" id="complete">Download</th>
<th align="left" id="delete">Delete</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
% endif
</div> </div>
</div> </div>
</div> </div>
@ -335,8 +488,7 @@ DOCUMENTATION :: END
</%def> </%def>
<%def name="modalIncludes()"> <%def name="modalIncludes()">
<div id="edit-library-modal" class="modal fade" tabindex="-1" role="dialog" <div id="edit-library-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="edit-library-modal">
aria-labelledby="edit-library-modal">
</div> </div>
<div class="modal fade" id="info-modal" tabindex="-1" role="dialog" aria-labelledby="info-modal"> <div class="modal fade" id="info-modal" tabindex="-1" role="dialog" aria-labelledby="info-modal">
</div> </div>
@ -360,6 +512,8 @@ DOCUMENTATION :: END
</div> </div>
</div> </div>
</div> </div>
<div id="export-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="export-modal">
</div>
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
@ -369,6 +523,9 @@ DOCUMENTATION :: END
<script src="${http_root}js/dataTables.bootstrap.pagination.js"></script> <script src="${http_root}js/dataTables.bootstrap.pagination.js"></script>
% if data: % if data:
<% from plexpy.common import LIVE_TV_SECTION_ID %> <% from plexpy.common import LIVE_TV_SECTION_ID %>
<%
history_user_id = '' if _session['user_group'] == 'admin' else _session['user_id']
%>
<script> <script>
% if str(data['section_id']).isdigit(): % if str(data['section_id']).isdigit():
var section_id = ${data['section_id']}; var section_id = ${data['section_id']};
@ -387,11 +544,16 @@ DOCUMENTATION :: END
<script src="${http_root}js/moment-with-locale.js"></script> <script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/tables/history_table.js${cache_param}"></script> <script src="${http_root}js/tables/history_table.js${cache_param}"></script>
<script src="${http_root}js/tables/media_info_table.js${cache_param}"></script> <script src="${http_root}js/tables/media_info_table.js${cache_param}"></script>
<script src="${http_root}js/tables/collections_table.js${cache_param}"></script>
<script src="${http_root}js/tables/playlists_table.js${cache_param}"></script>
<script src="${http_root}js/tables/export_table.js${cache_param}"></script>
<script> <script>
$('a[data-toggle="tab"]').on('shown.bs.tab', function (e) { $('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
$.fn.dataTable.tables({ visible: true, api: true }).columns.adjust(); $.fn.dataTable.tables({ visible: true, api: true }).columns.adjust();
}); });
$(".inactive-library-tooltip").tooltip();
function loadHistoryTable() { function loadHistoryTable() {
// Build watch history table // Build watch history table
history_table_options.ajax = { history_table_options.ajax = {
@ -401,7 +563,7 @@ DOCUMENTATION :: END
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),
section_id: section_id, section_id: section_id,
user_id: "${_session['user_group']}" == "admin" ? null : "${_session['user_id']}" user_id: "${history_user_id}"
}; };
} }
}; };
@ -423,96 +585,65 @@ DOCUMENTATION :: END
history_table.draw(); history_table.draw();
}); });
$(".inactive-library-tooltip").tooltip(); function loadCollectionsTable() {
// Build collections table
% if _session['user_group'] == 'admin': collections_table_options.ajax = {
function loadMediaInfoTable() { url: 'get_collections_list',
// Build media info table
media_info_table_options.ajax = {
url: 'get_library_media_info',
type: 'POST', type: 'POST',
data: function ( d ) { data: function ( d ) {
return { return {
json_data: JSON.stringify( d ), json_data: JSON.stringify( d ),
section_id: section_id, section_id: section_id
refresh: refresh_table
}; };
} }
}; };
media_info_table = $('#media_info_table-SID-${data["section_id"]}').DataTable(media_info_table_options); collections_table = $('#collections_table-SID-${data["section_id"]}').DataTable(collections_table_options);
var colvis = new $.fn.dataTable.ColVis(media_info_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' }); var colvis = new $.fn.dataTable.ColVis(collections_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-media-info'); $(colvis.button()).appendTo('#button-bar-collections');
clearSearchButton('media_info_table-SID-${data["section_id"]}', media_info_table); clearSearchButton('collections_table-SID-${data["section_id"]}', collections_table);
} }
$('a[href="#tabs-mediainfo"]').on('shown.bs.tab', function() { $('a[href="#tabs-collections"]').on('shown.bs.tab', function() {
if (typeof(media_info_table) === 'undefined') { if (typeof(collections_table) === 'undefined') {
loadMediaInfoTable(); loadCollectionsTable();
} }
}); });
$("#refresh-media-info-table").click(function () { $("#refresh-collections-table").click(function () {
media_info_child_table = {}; collections_table.draw();
refresh_table = true;
refresh_child_tables = true;
media_info_table.draw();
refresh_table = false;
}); });
$("#edit-library-tooltip").tooltip(); function loadPlaylistsTable() {
// Build playlists table
// Load edit library modal playlists_table_options.ajax = {
$("#toggle-edit-library-modal").click(function() { url: 'get_playlists_list',
$("#edit-library-tooltip").tooltip('hide');
$.ajax({
url: 'edit_library_dialog',
data: { section_id: section_id },
cache: false,
async: true,
complete: function(xhr, status) {
$("#edit-library-modal").html(xhr.responseText);
}
});
});
$('#row-edit-mode').on('click', function() {
$('#row-edit-mode-alert').fadeIn(200);
if ($(this).hasClass('active')) {
if (history_to_delete.length > 0) {
$('#deleteCount').text(history_to_delete.length);
$('#confirm-modal-delete').modal();
$('#confirm-modal-delete').one('click', '#confirm-delete', function () {
$.ajax({
url: 'delete_history_rows',
type: 'POST', type: 'POST',
data: { row_ids: history_to_delete.join(',') }, data: function ( d ) {
async: true, return {
success: function (data) { json_data: JSON.stringify( d ),
var msg = "History deleted"; section_id: section_id
showMsg(msg, false, true, 2000); };
history_table.draw();
} }
}); };
}); playlists_table = $('#playlists_table-SID-${data["section_id"]}').DataTable(playlists_table_options);
var colvis = new $.fn.dataTable.ColVis(playlists_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-playlists');
clearSearchButton('playlists_table-SID-${data["section_id"]}', playlists_table);
} }
$('.delete-control').each(function () { $('a[href="#tabs-playlists"]').on('shown.bs.tab', function() {
$(this).addClass('hidden'); if (typeof(playlists_table) === 'undefined') {
$('#row-edit-mode-alert').fadeOut(200); loadPlaylistsTable();
});
} else {
history_to_delete = [];
$('.delete-control').each(function() {
$(this).find('button.btn-danger').toggleClass('btn-warning').toggleClass('btn-danger');
$(this).removeClass('hidden');
});
} }
}); });
% endif
$("#refresh-playlists-table").click(function () {
playlists_table.draw();
});
function recentlyWatched() { function recentlyWatched() {
// Populate recently watched // Populate recently watched
@ -634,11 +765,11 @@ DOCUMENTATION :: END
var hash = document.location.hash; var hash = document.location.hash;
var prefix = "tab_"; var prefix = "tab_";
if (hash) { if (hash) {
$('.user-info-nav a[href='+hash.replace(prefix,"")+']').tab('show').trigger('show.bs.tab'); $('.nav-list a[href=' + hash.replace(prefix, "") + ']').tab('show').trigger('show.bs.tab');
} }
// Change hash for page-reload // Change hash for page-reload
$('.user-info-nav a').on('shown.bs.tab', function (e) { $('.nav-list a').on('shown.bs.tab', function (e) {
window.location.hash = e.target.hash.replace("#", "#" + prefix); window.location.hash = e.target.hash.replace("#", "#" + prefix);
}); });
@ -664,5 +795,143 @@ DOCUMENTATION :: END
}); });
</script> </script>
% if _session['user_group'] == 'admin':
<script>
function loadMediaInfoTable() {
// Build media info table
media_info_table_options.ajax = {
url: 'get_library_media_info',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
section_id: section_id,
refresh: refresh_table
};
}
};
media_info_table = $('#media_info_table-SID-${data["section_id"]}').DataTable(media_info_table_options);
var colvis = new $.fn.dataTable.ColVis(media_info_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-media-info');
clearSearchButton('media_info_table-SID-${data["section_id"]}', media_info_table);
}
$('a[href="#tabs-mediainfo"]').on('shown.bs.tab', function() {
if (typeof(media_info_table) === 'undefined') {
loadMediaInfoTable();
}
});
$("#refresh-media-info-table").click(function () {
media_info_child_table = {};
refresh_table = true;
refresh_child_tables = true;
media_info_table.draw();
refresh_table = false;
});
function loadExportTable() {
// Build export table
export_table_options.ajax = {
url: 'get_export_list',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
section_id: section_id
};
}
};
export_table = $('#export_table-SID-${data["section_id"]}').DataTable(export_table_options);
export_table.columns([7]).visible(false);
var colvis = new $.fn.dataTable.ColVis(export_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-export');
clearSearchButton('export_table-SID-${data["section_id"]}', export_table);
}
$('a[href="#tabs-export"]').on('shown.bs.tab', function() {
if (typeof(export_table) === 'undefined') {
loadExportTable();
}
});
$("#refresh-export-table").click(function () {
export_table.draw();
});
$("#edit-library-tooltip").tooltip();
// Load edit library modal
$("#toggle-edit-library-modal").click(function() {
$("#edit-library-tooltip").tooltip('hide');
$.ajax({
url: 'edit_library_dialog',
data: { section_id: section_id },
cache: false,
async: true,
complete: function(xhr, status) {
$("#edit-library-modal").html(xhr.responseText);
}
});
});
$(".export-button").click(function() {
$.ajax({
url: 'export_metadata_modal',
data: {
section_id: $(this).data('section_id'),
media_type: $(this).data('media_type'),
sub_media_type: $(this).data('sub_media_type'),
export_type: $(this).data('export_type')
},
cache: false,
async: true,
complete: function(xhr, status) {
$("#export-modal").html(xhr.responseText);
}
});
});
$('#row-edit-mode').on('click', function() {
$('#row-edit-mode-alert').fadeIn(200);
if ($(this).hasClass('active')) {
if (history_to_delete.length > 0) {
$('#deleteCount').text(history_to_delete.length);
$('#confirm-modal-delete').modal();
$('#confirm-modal-delete').one('click', '#confirm-delete', function () {
$.ajax({
url: 'delete_history_rows',
type: 'POST',
data: { row_ids: history_to_delete.join(',') },
async: true,
success: function (data) {
var msg = "History deleted";
showMsg(msg, false, true, 2000);
history_table.draw();
}
});
});
}
$('.delete-control').each(function () {
$(this).addClass('hidden');
$('#row-edit-mode-alert').fadeOut(200);
});
} else {
history_to_delete = [];
$('.delete-control').each(function() {
$(this).find('button.btn-danger').toggleClass('btn-warning').toggleClass('btn-danger');
$(this).removeClass('hidden');
});
}
});
</script>
% endif
% endif % endif
</%def> </%def>

View file

@ -17,8 +17,6 @@
</%def> </%def>
<%def name="headerIncludes()"> <%def name="headerIncludes()">
<link href="${http_root}css/selectize.bootstrap3.css" rel="stylesheet" />
<link href="${http_root}css/selectize.min.css" rel="stylesheet" />
</%def> </%def>
<%def name="body()"> <%def name="body()">
@ -1457,6 +1455,22 @@
</div> </div>
</div> </div>
</div> </div>
<div class="form-group">
<label for="export_dir">Export Directory</label> ${docker_msg | n}
<div class="row">
<div class="col-md-7">
<div class="input-group">
<input type="text" class="form-control directory-settings" id="export_dir" name="export_dir" value="${config['export_dir']}" ${docker_setting}>
<span class="input-group-btn">
<button class="btn btn-form" type="button" id="export_dir_browse" data-toggle="browse" data-filter=".folderonly" data-target="#export_dir" ${docker_setting}>Browse</button>
</span>
</div>
<div class="btn-group">
<button class="btn btn-form" type="button" id="clear_exports">Clear All Exports</button>
</div>
</div>
</div>
</div>
<p><input type="button" class="btn btn-bright save-button" value="Save" data-success="Changes saved successfully"></p> <p><input type="button" class="btn btn-bright save-button" value="Save" data-success="Changes saved successfully"></p>
@ -1986,7 +2000,6 @@ Rating: {rating}/10 --> Rating: /10
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
<script src="${http_root}js/parsley.min.js"></script> <script src="${http_root}js/parsley.min.js"></script>
<script src="${http_root}js/Sortable.min.js"></script> <script src="${http_root}js/Sortable.min.js"></script>
<script src="${http_root}js/selectize.min.js"></script>
<script src="${http_root}js/moment-with-locale.js"></script> <script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/jquery.qrcode.min.js"></script> <script src="${http_root}js/jquery.qrcode.min.js"></script>
<script> <script>
@ -2307,6 +2320,12 @@ $(document).ready(function() {
confirmAjaxCall(url, msg); confirmAjaxCall(url, msg);
}); });
$("#clear_exports").click(function () {
var msg = 'Are you sure you want to clear the Tautulli metadata exports?';
var url = 'delete_export?delete_all=true';
confirmAjaxCall(url, msg);
});
$("#clear_logs").click(function () { $("#clear_logs").click(function () {
var msg = 'Are you sure you want to clear the Tautulli logs?'; var msg = 'Are you sure you want to clear the Tautulli logs?';
var url = 'delete_logs'; var url = 'delete_logs';

View file

@ -43,7 +43,9 @@ DOCUMENTATION :: END
<div class="summary-navbar"> <div class="summary-navbar">
<div class="col-md-12"> <div class="col-md-12">
<div class="summary-navbar-list"> <div class="summary-navbar-list">
<ul class="list-unstyled breadcrumb"></ul> <ul class="list-unstyled breadcrumb">
<li class="active">${data['friendly_name']}</li>
</ul>
</div> </div>
</div> </div>
</div> </div>
@ -67,12 +69,16 @@ DOCUMENTATION :: END
% endif % endif
</div> </div>
<div class="user-info-nav"> <div class="user-info-nav">
<ul class="user-info-nav" role="tablist"> <ul class="nav nav-list nav-pills" role="tablist">
<li class="active"><a href="#tabs-profile" role="tab" data-toggle="tab">Profile</a></li> <li class="active"><a href="#tabs-profile" role="tab" data-toggle="tab">Profile</a></li>
<li><a id="history-tab-btn" href="#tabs-history" role="tab" data-toggle="tab">History</a></li> <li><a href="#tabs-history" role="tab" data-toggle="tab">History</a></li>
<li><a id="sync-tab-btn" href="#tabs-synceditems" role="tab" data-toggle="tab">Synced Items</a></li> <li><a href="#tabs-playlists" role="tab" data-toggle="tab">Playlists</a></li>
<li><a id="ip-tab-btn" href="#tabs-ipaddresses" role="tab" data-toggle="tab">IP Addresses</a></li> % if _session['user_group'] == 'admin':
<li><a id="login-tab-btn" href="#tabs-tautullilogins" role="tab" data-toggle="tab">Tautulli Logins</a></li> <li><a href="#tabs-export" role="tab" data-toggle="tab">Export</a></li>
% endif
<li><a href="#tabs-synceditems" role="tab" data-toggle="tab">Synced Items</a></li>
<li><a href="#tabs-ipaddresses" role="tab" data-toggle="tab">IP Addresses</a></li>
<li><a href="#tabs-tautullilogins" role="tab" data-toggle="tab">Tautulli Logins</a></li>
</ul> </ul>
</div> </div>
</div> </div>
@ -210,6 +216,99 @@ DOCUMENTATION :: END
</div> </div>
</div> </div>
</div> </div>
<div role="tabpanel" class="tab-pane" id="tabs-playlists">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class='table-card-header'>
<div class="header-bar">
<span>
<i class="fa fa-list-alt"></i> Playlists for <strong>
<span class="set-username">${data['friendly_name']}</span>
</strong>
</span>
</div>
<div class="button-bar">
% if _session['user_group'] == 'admin':
<div class="btn-group">
<button class="btn btn-dark export-button" id="toggle-export-modal" data-toggle="modal" data-target="#export-modal"
data-user_id="${data['user_id']}" data-media_type="playlist" data-sub_media_type="video,audio,photo"
data-export_type="playlist">
<i class="fa fa-file-export"></i> Export playlists
</button>
</div>
% endif
<div class="btn-group">
<button class="btn btn-dark refresh-playlists-table-button" id="refresh-playlists-table">
<i class="fa fa-refresh"></i> Refresh playlists
</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-playlists"></div>
</div>
</div>
<div class="table-card-back">
<table class="display playlists_table" id="playlists_table-SID-${data['user_id']}" width="100%">
<thead>
<tr>
<th align="left" id="playlistTitle">Playlist Title</th>
<th align="left" id="playlistLeafCount">Playlist Items</th>
<th align="left" id="playlistDuration">Playlist Duration</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
% if _session['user_group'] == 'admin':
<div role="tabpanel" class="tab-pane" id="tabs-export">
<div class="container-fluid">
<div class="row">
<div class="col-md-12">
<div class='table-card-header'>
<div class="header-bar">
<span>
<i class="fa fa-file-export"></i> Metadata Exports for <strong>
<span class="set-username">${data['friendly_name']}</span>
</strong>
</span>
</div>
<div class="button-bar">
<div class="btn-group">
<button class="btn btn-dark refresh-export-table-button" id="refresh-export-table">
<i class="fa fa-refresh"></i> Refresh exports
</button>
</div>
<div class="btn-group colvis-button-bar" id="button-bar-export"></div>
</div>
</div>
<div class="table-card-back">
<table class="display export_table" id="export_table-SID-${data['user_id']}" width="100%">
<thead>
<tr>
<th align="left" id="timestamp">Exported At</th>
<th align="left" id="media_type_title">Media Type</th>
<th align="left" id="rating_key">Rating Key</th>
<th align="left" id="filename">Filename</th>
<th align="left" id="file_format">File Format</th>
<th align="left" id="metadata_level">Metadata Level</th>
<th align="left" id="media_info_level">Media Info Level</th>
<th align="left" id="media_info_level">Custom Fields</th>
<th align="left" id="file_size">File Size</th>
<th align="left" id="complete">Download</th>
<th align="left" id="delete">Delete</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
% endif
<div role="tabpanel" class="tab-pane" id="tabs-synceditems"> <div role="tabpanel" class="tab-pane" id="tabs-synceditems">
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
@ -393,6 +492,8 @@ DOCUMENTATION :: END
</div> </div>
</div> </div>
</div> </div>
<div id="export-modal" class="modal fade" tabindex="-1" role="dialog" aria-labelledby="export-modal">
</div>
</%def> </%def>
<%def name="javascriptIncludes()"> <%def name="javascriptIncludes()">
@ -412,6 +513,8 @@ DOCUMENTATION :: END
</script> </script>
<script src="${http_root}js/moment-with-locale.js"></script> <script src="${http_root}js/moment-with-locale.js"></script>
<script src="${http_root}js/tables/history_table.js${cache_param}"></script> <script src="${http_root}js/tables/history_table.js${cache_param}"></script>
<script src="${http_root}js/tables/playlists_table.js${cache_param}"></script>
<script src="${http_root}js/tables/export_table.js${cache_param}"></script>
<script src="${http_root}js/tables/user_ips.js${cache_param}"></script> <script src="${http_root}js/tables/user_ips.js${cache_param}"></script>
<script src="${http_root}js/tables/sync_table.js${cache_param}"></script> <script src="${http_root}js/tables/sync_table.js${cache_param}"></script>
<script src="${http_root}js/tables/login_logs.js${cache_param}"></script> <script src="${http_root}js/tables/login_logs.js${cache_param}"></script>
@ -420,6 +523,8 @@ DOCUMENTATION :: END
$.fn.dataTable.tables({ visible: true, api: true }).columns.adjust(); $.fn.dataTable.tables({ visible: true, api: true }).columns.adjust();
}); });
$(".inactive-user-tooltip").tooltip();
function loadHistoryTable(media_type) { function loadHistoryTable(media_type) {
// Build watch history table // Build watch history table
history_table_options.ajax = { history_table_options.ajax = {
@ -451,6 +556,49 @@ DOCUMENTATION :: END
}); });
} }
$('a[href="#tabs-history"]').on('shown.bs.tab', function() {
if (typeof(history_table) === 'undefined') {
var media_type = getLocalStorage('user_' + user_id + '-history_media_type', 'all');
$('#history-' + media_type).prop('checked', true);
$('#history-' + media_type).closest('label').addClass('active');
loadHistoryTable(media_type);
}
});
$("#refresh-history-list").click(function () {
history_table.draw();
});
function loadPlaylistsTable() {
// Build playlists table
playlists_table_options.ajax = {
url: 'get_playlists_list',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
user_id: user_id
};
}
};
playlists_table = $('#playlists_table-SID-${data["user_id"]}').DataTable(playlists_table_options);
var colvis = new $.fn.dataTable.ColVis(playlists_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-playlists');
clearSearchButton('playlists_table-SID-${data["user_id"]}', playlists_table);
}
$('a[href="#tabs-playlists"]').on('shown.bs.tab', function() {
if (typeof(playlists_table) === 'undefined') {
loadPlaylistsTable();
}
});
$("#refresh-playlists-table").click(function () {
playlists_table.draw();
});
function loadSyncTable() { function loadSyncTable() {
// Build user sync table // Build user sync table
sync_table_options.ajax = { sync_table_options.ajax = {
@ -466,6 +614,16 @@ DOCUMENTATION :: END
clearSearchButton('sync_table-UID-${data["user_id"]}', sync_table); clearSearchButton('sync_table-UID-${data["user_id"]}', sync_table);
} }
$('a[href="#tabs-synceditems"]').on('shown.bs.tab', function() {
if (typeof(sync_table) === 'undefined') {
loadSyncTable(user_id);
}
});
$("#refresh-syncs-list").click(function() {
sync_table.ajax.reload();
});
function loadIPAddressTable() { function loadIPAddressTable() {
// Build user IP table // Build user IP table
user_ip_table_options.ajax = { user_ip_table_options.ajax = {
@ -483,6 +641,16 @@ DOCUMENTATION :: END
clearSearchButton('user_ip_table-UID-${data["user_id"]}', user_ip_table); clearSearchButton('user_ip_table-UID-${data["user_id"]}', user_ip_table);
} }
$('a[href="#tabs-ipaddresses"]').on('shown.bs.tab', function() {
if (typeof(user_ip_table) === 'undefined') {
loadIPAddressTable(user_id);
}
});
$("#refresh-ip-address-list").click(function () {
user_ip_table.draw();
});
function loadLoginTable() { function loadLoginTable() {
// Build user login table // Build user login table
login_log_table_options.ajax = { login_log_table_options.ajax = {
@ -504,52 +672,142 @@ DOCUMENTATION :: END
clearSearchButton('login_log_table-UID-${data["user_id"]}', login_log_table); clearSearchButton('login_log_table-UID-${data["user_id"]}', login_log_table);
} }
$('a[href="#tabs-history"]').on('shown.bs.tab', function() {
if (typeof(history_table) === 'undefined') {
var media_type = getLocalStorage('user_' + user_id + '-history_media_type', 'all');
$('#history-' + media_type).prop('checked', true);
$('#history-' + media_type).closest('label').addClass('active');
loadHistoryTable(media_type);
}
});
$('a[href="#tabs-synceditems"]').on('shown.bs.tab', function() {
if (typeof(sync_table) === 'undefined') {
loadSyncTable(user_id);
}
});
$('a[href="#tabs-ipaddresses"]').on('shown.bs.tab', function() {
if (typeof(user_ip_table) === 'undefined') {
loadIPAddressTable(user_id);
}
});
$('a[href="#tabs-tautullilogins"]').on('shown.bs.tab', function() { $('a[href="#tabs-tautullilogins"]').on('shown.bs.tab', function() {
if (typeof(login_log_table) === 'undefined') { if (typeof(login_log_table) === 'undefined') {
loadLoginTable(user_id); loadLoginTable(user_id);
} }
}); });
$("#refresh-history-list").click(function () {
history_table.draw();
});
$("#refresh-syncs-list").click(function() {
sync_table.ajax.reload();
});
$("#refresh-ip-address-list").click(function () {
user_ip_table.draw();
});
$("#refresh-login-list").click(function () { $("#refresh-login-list").click(function () {
login_log_table.draw(); login_log_table.draw();
}); });
$(".inactive-user-tooltip").tooltip(); function recentlyWatched() {
// Populate recently watched
$.ajax({
url: 'get_user_recently_watched',
async: true,
data: {
user_id: user_id,
limit: 50
},
complete: function(xhr, status) {
$("#user-recently-watched").html(xhr.responseText);
highlightWatchedScrollerButton();
}
});
}
recentlyWatched();
function highlightWatchedScrollerButton() {
var scroller = $("#recently-watched-row-scroller");
var numElems = scroller.find("li").length;
scroller.width(numElems * 175);
if (scroller.width() > $("#user-recently-watched").width()) {
$("#recently-watched-page-right").removeClass("disabled");
} else {
$("#recently-watched-page-right").addClass("disabled");
}
}
$(window).resize(function() {
highlightWatchedScrollerButton();
});
var leftTotal = 0;
$(".paginate").click(function (e) {
e.preventDefault();
var scroller = $("#recently-watched-row-scroller");
var containerWidth = $("#user-recently-watched").width();
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
leftTotal = Math.max(Math.min(leftTotal + scrollAmount, 0), leftMax);
scroller.animate({ left: leftTotal }, 250);
if (leftTotal == 0) {
$("#recently-watched-page-left").addClass("disabled").blur();
} else {
$("#recently-watched-page-left").removeClass("disabled");
}
if (leftTotal == leftMax) {
$("#recently-watched-page-right").addClass("disabled").blur();
} else {
$("#recently-watched-page-right").removeClass("disabled");
}
});
$(document).ready(function () {
// Javascript to enable link to tab
var hash = document.location.hash;
var prefix = "tab_";
if (hash) {
$('.nav-list a[href=' + hash.replace(prefix, "") + ']').tab('show').trigger('show.bs.tab');
}
// Change hash for page-reload
$('.nav-list a').on('shown.bs.tab', function (e) {
window.location.hash = e.target.hash.replace("#", "#" + prefix);
});
// Populate watch time stats
$.ajax({
url: 'user_watch_time_stats',
async: true,
data: { user_id: user_id, user: username },
complete: function(xhr, status) {
$("#user-time-stats").html(xhr.responseText);
}
});
// Populate platform stats
$.ajax({
url: 'user_player_stats',
async: true,
data: { user_id: user_id, user: username },
complete: function(xhr, status) {
$("#user-player-stats").html(xhr.responseText);
}
});
});
</script>
% if _session['user_group'] == 'admin': % if _session['user_group'] == 'admin':
<script>
function loadExportTable() {
// Build export table
export_table_options.ajax = {
url: 'get_export_list',
type: 'POST',
data: function ( d ) {
return {
json_data: JSON.stringify( d ),
user_id: user_id
};
}
};
export_table = $('#export_table-SID-${data["user_id"]}').DataTable(export_table_options);
export_table.columns([2, 7]).visible(false);
var colvis = new $.fn.dataTable.ColVis(export_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
$(colvis.button()).appendTo('#button-bar-export');
clearSearchButton('export_table-SID-${data["user_id"]}', export_table);
}
$('a[href="#tabs-export"]').on('shown.bs.tab', function() {
if (typeof(export_table) === 'undefined') {
loadExportTable();
}
});
$("#refresh-export-table").click(function () {
export_table.draw();
});
$("#edit-user-tooltip").tooltip(); $("#edit-user-tooltip").tooltip();
// Load edit user modal // Load edit user modal
@ -566,6 +824,23 @@ DOCUMENTATION :: END
}); });
}); });
$(".export-button").click(function() {
$.ajax({
url: 'export_metadata_modal',
data: {
user_id: $(this).data('user_id'),
media_type: $(this).data('media_type'),
sub_media_type: $(this).data('sub_media_type'),
export_type: $(this).data('export_type')
},
cache: false,
async: true,
complete: function(xhr, status) {
$("#export-modal").html(xhr.responseText);
}
});
});
$('#row-edit-mode').on('click', function() { $('#row-edit-mode').on('click', function() {
$('#row-edit-mode-alert').fadeIn(200); $('#row-edit-mode-alert').fadeIn(200);
@ -644,100 +919,7 @@ DOCUMENTATION :: END
}); });
} }
}); });
% endif
function recentlyWatched() {
// Populate recently watched
$.ajax({
url: 'get_user_recently_watched',
async: true,
data: {
user_id: user_id,
limit: 50
},
complete: function(xhr, status) {
$("#user-recently-watched").html(xhr.responseText);
highlightWatchedScrollerButton();
}
});
}
recentlyWatched();
function highlightWatchedScrollerButton() {
var scroller = $("#recently-watched-row-scroller");
var numElems = scroller.find("li").length;
scroller.width(numElems * 175);
if (scroller.width() > $("#user-recently-watched").width()) {
$("#recently-watched-page-right").removeClass("disabled");
} else {
$("#recently-watched-page-right").addClass("disabled");
}
}
$(window).resize(function() {
highlightWatchedScrollerButton();
});
var leftTotal = 0;
$(".paginate").click(function (e) {
e.preventDefault();
var scroller = $("#recently-watched-row-scroller");
var containerWidth = $("#user-recently-watched").width();
var scrollAmount = $(this).data("id") * parseInt(containerWidth / 175) * 175;
var leftMax = Math.min(-parseInt(scroller.width()) + Math.abs(scrollAmount), 0);
leftTotal = Math.max(Math.min(leftTotal + scrollAmount, 0), leftMax);
scroller.animate({ left: leftTotal }, 250);
if (leftTotal == 0) {
$("#recently-watched-page-left").addClass("disabled").blur();
} else {
$("#recently-watched-page-left").removeClass("disabled");
}
if (leftTotal == leftMax) {
$("#recently-watched-page-right").addClass("disabled").blur();
} else {
$("#recently-watched-page-right").removeClass("disabled");
}
});
$(document).ready(function () {
// Javascript to enable link to tab
var hash = document.location.hash;
var prefix = "tab_";
if (hash) {
$('.user-info-nav a[href='+hash.replace(prefix,"")+']').tab('show').trigger('show.bs.tab');
}
// Change hash for page-reload
$('.user-info-nav a').on('shown.bs.tab', function (e) {
window.location.hash = e.target.hash.replace("#", "#" + prefix);
});
// Populate watch time stats
$.ajax({
url: 'user_watch_time_stats',
async: true,
data: { user_id: user_id, user: username },
complete: function(xhr, status) {
$("#user-time-stats").html(xhr.responseText);
}
});
// Populate platform stats
$.ajax({
url: 'user_player_stats',
async: true,
data: { user_id: user_id, user: username },
complete: function(xhr, status) {
$("#user-player-stats").html(xhr.responseText);
}
});
});
</script> </script>
% endif % endif
% endif
</%def> </%def>

View file

@ -1 +1 @@
__path__ = __import__('pkgutil').extend_path(__path__, __name__) __import__('pkg_resources').declare_namespace(__name__)

979
lib/backports/csv.py Normal file
View file

@ -0,0 +1,979 @@
# -*- coding: utf-8 -*-
"""A port of Python 3's csv module to Python 2.
The API of the csv module in Python 2 is drastically different from
the csv module in Python 3. This is due, for the most part, to the
difference between str in Python 2 and Python 3.
The semantics of Python 3's version are more useful because they support
unicode natively, while Python 2's csv does not.
"""
from __future__ import unicode_literals, absolute_import
__all__ = [ "QUOTE_MINIMAL", "QUOTE_ALL", "QUOTE_NONNUMERIC", "QUOTE_NONE",
"Error", "Dialect", "__doc__", "excel", "excel_tab",
"field_size_limit", "reader", "writer",
"register_dialect", "get_dialect", "list_dialects", "Sniffer",
"unregister_dialect", "__version__", "DictReader", "DictWriter" ]
import re
import numbers
from io import StringIO
from csv import (
QUOTE_MINIMAL, QUOTE_ALL, QUOTE_NONNUMERIC, QUOTE_NONE,
__version__, __doc__, Error, field_size_limit,
)
# Stuff needed from six
import sys
PY3 = sys.version_info[0] == 3
if PY3:
string_types = str
text_type = str
binary_type = bytes
unichr = chr
else:
string_types = basestring
text_type = unicode
binary_type = str
class QuoteStrategy(object):
quoting = None
def __init__(self, dialect):
if self.quoting is not None:
assert dialect.quoting == self.quoting
self.dialect = dialect
self.setup()
escape_pattern_quoted = r'({quotechar})'.format(
quotechar=re.escape(self.dialect.quotechar or '"'))
escape_pattern_unquoted = r'([{specialchars}])'.format(
specialchars=re.escape(self.specialchars))
self.escape_re_quoted = re.compile(escape_pattern_quoted)
self.escape_re_unquoted = re.compile(escape_pattern_unquoted)
def setup(self):
"""Optional method for strategy-wide optimizations."""
def quoted(self, field=None, raw_field=None, only=None):
"""Determine whether this field should be quoted."""
raise NotImplementedError(
'quoted must be implemented by a subclass')
@property
def specialchars(self):
"""The special characters that need to be escaped."""
raise NotImplementedError(
'specialchars must be implemented by a subclass')
def escape_re(self, quoted=None):
if quoted:
return self.escape_re_quoted
return self.escape_re_unquoted
def escapechar(self, quoted=None):
if quoted and self.dialect.doublequote:
return self.dialect.quotechar
return self.dialect.escapechar
def prepare(self, raw_field, only=None):
field = text_type(raw_field if raw_field is not None else '')
quoted = self.quoted(field=field, raw_field=raw_field, only=only)
escape_re = self.escape_re(quoted=quoted)
escapechar = self.escapechar(quoted=quoted)
if escape_re.search(field):
escapechar = '\\\\' if escapechar == '\\' else escapechar
if not escapechar:
raise Error('No escapechar is set')
escape_replace = r'{escapechar}\1'.format(escapechar=escapechar)
field = escape_re.sub(escape_replace, field)
if quoted:
field = '{quotechar}{field}{quotechar}'.format(
quotechar=self.dialect.quotechar, field=field)
return field
class QuoteMinimalStrategy(QuoteStrategy):
quoting = QUOTE_MINIMAL
def setup(self):
self.quoted_re = re.compile(r'[{specialchars}]'.format(
specialchars=re.escape(self.specialchars)))
@property
def specialchars(self):
return (
self.dialect.lineterminator +
self.dialect.quotechar +
self.dialect.delimiter +
(self.dialect.escapechar or '')
)
def quoted(self, field, only, **kwargs):
if field == self.dialect.quotechar and not self.dialect.doublequote:
# If the only character in the field is the quotechar, and
# doublequote is false, then just escape without outer quotes.
return False
return field == '' and only or bool(self.quoted_re.search(field))
class QuoteAllStrategy(QuoteStrategy):
quoting = QUOTE_ALL
@property
def specialchars(self):
return self.dialect.quotechar
def quoted(self, **kwargs):
return True
class QuoteNonnumericStrategy(QuoteStrategy):
quoting = QUOTE_NONNUMERIC
@property
def specialchars(self):
return (
self.dialect.lineterminator +
self.dialect.quotechar +
self.dialect.delimiter +
(self.dialect.escapechar or '')
)
def quoted(self, raw_field, **kwargs):
return not isinstance(raw_field, numbers.Number)
class QuoteNoneStrategy(QuoteStrategy):
quoting = QUOTE_NONE
@property
def specialchars(self):
return (
self.dialect.lineterminator +
(self.dialect.quotechar or '') +
self.dialect.delimiter +
(self.dialect.escapechar or '')
)
def quoted(self, field, only, **kwargs):
if field == '' and only:
raise Error('single empty field record must be quoted')
return False
class writer(object):
def __init__(self, fileobj, dialect='excel', **fmtparams):
if fileobj is None:
raise TypeError('fileobj must be file-like, not None')
self.fileobj = fileobj
if isinstance(dialect, text_type):
dialect = get_dialect(dialect)
try:
self.dialect = Dialect.combine(dialect, fmtparams)
except Error as e:
raise TypeError(*e.args)
strategies = {
QUOTE_MINIMAL: QuoteMinimalStrategy,
QUOTE_ALL: QuoteAllStrategy,
QUOTE_NONNUMERIC: QuoteNonnumericStrategy,
QUOTE_NONE: QuoteNoneStrategy,
}
self.strategy = strategies[self.dialect.quoting](self.dialect)
def writerow(self, row):
if row is None:
raise Error('row must be an iterable')
row = list(row)
only = len(row) == 1
row = [self.strategy.prepare(field, only=only) for field in row]
line = self.dialect.delimiter.join(row) + self.dialect.lineterminator
return self.fileobj.write(line)
def writerows(self, rows):
for row in rows:
self.writerow(row)
START_RECORD = 0
START_FIELD = 1
ESCAPED_CHAR = 2
IN_FIELD = 3
IN_QUOTED_FIELD = 4
ESCAPE_IN_QUOTED_FIELD = 5
QUOTE_IN_QUOTED_FIELD = 6
EAT_CRNL = 7
AFTER_ESCAPED_CRNL = 8
class reader(object):
def __init__(self, fileobj, dialect='excel', **fmtparams):
self.input_iter = iter(fileobj)
if isinstance(dialect, text_type):
dialect = get_dialect(dialect)
try:
self.dialect = Dialect.combine(dialect, fmtparams)
except Error as e:
raise TypeError(*e.args)
self.fields = None
self.field = None
self.line_num = 0
def parse_reset(self):
self.fields = []
self.field = []
self.state = START_RECORD
self.numeric_field = False
def parse_save_field(self):
field = ''.join(self.field)
self.field = []
if self.numeric_field:
field = float(field)
self.numeric_field = False
self.fields.append(field)
def parse_add_char(self, c):
if len(self.field) >= field_size_limit():
raise Error('field size limit exceeded')
self.field.append(c)
def parse_process_char(self, c):
switch = {
START_RECORD: self._parse_start_record,
START_FIELD: self._parse_start_field,
ESCAPED_CHAR: self._parse_escaped_char,
AFTER_ESCAPED_CRNL: self._parse_after_escaped_crnl,
IN_FIELD: self._parse_in_field,
IN_QUOTED_FIELD: self._parse_in_quoted_field,
ESCAPE_IN_QUOTED_FIELD: self._parse_escape_in_quoted_field,
QUOTE_IN_QUOTED_FIELD: self._parse_quote_in_quoted_field,
EAT_CRNL: self._parse_eat_crnl,
}
return switch[self.state](c)
def _parse_start_record(self, c):
if c == '\0':
return
elif c == '\n' or c == '\r':
self.state = EAT_CRNL
return
self.state = START_FIELD
return self._parse_start_field(c)
def _parse_start_field(self, c):
if c == '\n' or c == '\r' or c == '\0':
self.parse_save_field()
self.state = START_RECORD if c == '\0' else EAT_CRNL
elif (c == self.dialect.quotechar and
self.dialect.quoting != QUOTE_NONE):
self.state = IN_QUOTED_FIELD
elif c == self.dialect.escapechar:
self.state = ESCAPED_CHAR
elif c == ' ' and self.dialect.skipinitialspace:
pass # Ignore space at start of field
elif c == self.dialect.delimiter:
# Save empty field
self.parse_save_field()
else:
# Begin new unquoted field
if self.dialect.quoting == QUOTE_NONNUMERIC:
self.numeric_field = True
self.parse_add_char(c)
self.state = IN_FIELD
def _parse_escaped_char(self, c):
if c == '\n' or c == '\r':
self.parse_add_char(c)
self.state = AFTER_ESCAPED_CRNL
return
if c == '\0':
c = '\n'
self.parse_add_char(c)
self.state = IN_FIELD
def _parse_after_escaped_crnl(self, c):
if c == '\0':
return
return self._parse_in_field(c)
def _parse_in_field(self, c):
# In unquoted field
if c == '\n' or c == '\r' or c == '\0':
# End of line - return [fields]
self.parse_save_field()
self.state = START_RECORD if c == '\0' else EAT_CRNL
elif c == self.dialect.escapechar:
self.state = ESCAPED_CHAR
elif c == self.dialect.delimiter:
self.parse_save_field()
self.state = START_FIELD
else:
# Normal character - save in field
self.parse_add_char(c)
def _parse_in_quoted_field(self, c):
if c == '\0':
pass
elif c == self.dialect.escapechar:
self.state = ESCAPE_IN_QUOTED_FIELD
elif (c == self.dialect.quotechar and
self.dialect.quoting != QUOTE_NONE):
if self.dialect.doublequote:
self.state = QUOTE_IN_QUOTED_FIELD
else:
self.state = IN_FIELD
else:
self.parse_add_char(c)
def _parse_escape_in_quoted_field(self, c):
if c == '\0':
c = '\n'
self.parse_add_char(c)
self.state = IN_QUOTED_FIELD
def _parse_quote_in_quoted_field(self, c):
if (self.dialect.quoting != QUOTE_NONE and
c == self.dialect.quotechar):
# save "" as "
self.parse_add_char(c)
self.state = IN_QUOTED_FIELD
elif c == self.dialect.delimiter:
self.parse_save_field()
self.state = START_FIELD
elif c == '\n' or c == '\r' or c == '\0':
# End of line = return [fields]
self.parse_save_field()
self.state = START_RECORD if c == '\0' else EAT_CRNL
elif not self.dialect.strict:
self.parse_add_char(c)
self.state = IN_FIELD
else:
# illegal
raise Error("{delimiter}' expected after '{quotechar}".format(
delimiter=self.dialect.delimiter,
quotechar=self.dialect.quotechar,
))
def _parse_eat_crnl(self, c):
if c == '\n' or c == '\r':
pass
elif c == '\0':
self.state = START_RECORD
else:
raise Error('new-line character seen in unquoted field - do you '
'need to open the file in universal-newline mode?')
def __iter__(self):
return self
def __next__(self):
self.parse_reset()
while True:
try:
lineobj = next(self.input_iter)
except StopIteration:
if len(self.field) != 0 or self.state == IN_QUOTED_FIELD:
if self.dialect.strict:
raise Error('unexpected end of data')
self.parse_save_field()
if self.fields:
break
raise
if not isinstance(lineobj, text_type):
typ = type(lineobj)
typ_name = 'bytes' if typ == bytes else typ.__name__
err_str = ('iterator should return strings, not {0}'
' (did you open the file in text mode?)')
raise Error(err_str.format(typ_name))
self.line_num += 1
for c in lineobj:
if c == '\0':
raise Error('line contains NULL byte')
self.parse_process_char(c)
self.parse_process_char('\0')
if self.state == START_RECORD:
break
fields = self.fields
self.fields = None
return fields
next = __next__
_dialect_registry = {}
def register_dialect(name, dialect='excel', **fmtparams):
if not isinstance(name, text_type):
raise TypeError('"name" must be a string')
dialect = Dialect.extend(dialect, fmtparams)
try:
Dialect.validate(dialect)
except:
raise TypeError('dialect is invalid')
assert name not in _dialect_registry
_dialect_registry[name] = dialect
def unregister_dialect(name):
try:
_dialect_registry.pop(name)
except KeyError:
raise Error('"{name}" not a registered dialect'.format(name=name))
def get_dialect(name):
try:
return _dialect_registry[name]
except KeyError:
raise Error('Could not find dialect {0}'.format(name))
def list_dialects():
return list(_dialect_registry)
class Dialect(object):
"""Describe a CSV dialect.
This must be subclassed (see csv.excel). Valid attributes are:
delimiter, quotechar, escapechar, doublequote, skipinitialspace,
lineterminator, quoting, strict.
"""
_name = ""
_valid = False
# placeholders
delimiter = None
quotechar = None
escapechar = None
doublequote = None
skipinitialspace = None
lineterminator = None
quoting = None
strict = None
def __init__(self):
self.validate(self)
if self.__class__ != Dialect:
self._valid = True
@classmethod
def validate(cls, dialect):
dialect = cls.extend(dialect)
if not isinstance(dialect.quoting, int):
raise Error('"quoting" must be an integer')
if dialect.delimiter is None:
raise Error('delimiter must be set')
cls.validate_text(dialect, 'delimiter')
if dialect.lineterminator is None:
raise Error('lineterminator must be set')
if not isinstance(dialect.lineterminator, text_type):
raise Error('"lineterminator" must be a string')
if dialect.quoting not in [
QUOTE_NONE, QUOTE_MINIMAL, QUOTE_NONNUMERIC, QUOTE_ALL]:
raise Error('Invalid quoting specified')
if dialect.quoting != QUOTE_NONE:
if dialect.quotechar is None and dialect.escapechar is None:
raise Error('quotechar must be set if quoting enabled')
if dialect.quotechar is not None:
cls.validate_text(dialect, 'quotechar')
@staticmethod
def validate_text(dialect, attr):
val = getattr(dialect, attr)
if not isinstance(val, text_type):
if type(val) == bytes:
raise Error('"{0}" must be string, not bytes'.format(attr))
raise Error('"{0}" must be string, not {1}'.format(
attr, type(val).__name__))
if len(val) != 1:
raise Error('"{0}" must be a 1-character string'.format(attr))
@staticmethod
def defaults():
return {
'delimiter': ',',
'doublequote': True,
'escapechar': None,
'lineterminator': '\r\n',
'quotechar': '"',
'quoting': QUOTE_MINIMAL,
'skipinitialspace': False,
'strict': False,
}
@classmethod
def extend(cls, dialect, fmtparams=None):
if isinstance(dialect, string_types):
dialect = get_dialect(dialect)
if fmtparams is None:
return dialect
defaults = cls.defaults()
if any(param not in defaults for param in fmtparams):
raise TypeError('Invalid fmtparam')
specified = dict(
(attr, getattr(dialect, attr, None))
for attr in cls.defaults()
)
specified.update(fmtparams)
return type(str('ExtendedDialect'), (cls,), specified)
@classmethod
def combine(cls, dialect, fmtparams):
"""Create a new dialect with defaults and added parameters."""
dialect = cls.extend(dialect, fmtparams)
defaults = cls.defaults()
specified = dict(
(attr, getattr(dialect, attr, None))
for attr in defaults
if getattr(dialect, attr, None) is not None or
attr in ['quotechar', 'delimiter', 'lineterminator', 'quoting']
)
defaults.update(specified)
dialect = type(str('CombinedDialect'), (cls,), defaults)
cls.validate(dialect)
return dialect()
def __delattr__(self, attr):
if self._valid:
raise AttributeError('dialect is immutable.')
super(Dialect, self).__delattr__(attr)
def __setattr__(self, attr, value):
if self._valid:
raise AttributeError('dialect is immutable.')
super(Dialect, self).__setattr__(attr, value)
class excel(Dialect):
"""Describe the usual properties of Excel-generated CSV files."""
delimiter = ','
quotechar = '"'
doublequote = True
skipinitialspace = False
lineterminator = '\r\n'
quoting = QUOTE_MINIMAL
register_dialect("excel", excel)
class excel_tab(excel):
"""Describe the usual properties of Excel-generated TAB-delimited files."""
delimiter = '\t'
register_dialect("excel-tab", excel_tab)
class unix_dialect(Dialect):
"""Describe the usual properties of Unix-generated CSV files."""
delimiter = ','
quotechar = '"'
doublequote = True
skipinitialspace = False
lineterminator = '\n'
quoting = QUOTE_ALL
register_dialect("unix", unix_dialect)
class DictReader(object):
def __init__(self, f, fieldnames=None, restkey=None, restval=None,
dialect="excel", *args, **kwds):
self._fieldnames = fieldnames # list of keys for the dict
self.restkey = restkey # key to catch long rows
self.restval = restval # default value for short rows
self.reader = reader(f, dialect, *args, **kwds)
self.dialect = dialect
self.line_num = 0
def __iter__(self):
return self
@property
def fieldnames(self):
if self._fieldnames is None:
try:
self._fieldnames = next(self.reader)
except StopIteration:
pass
self.line_num = self.reader.line_num
return self._fieldnames
@fieldnames.setter
def fieldnames(self, value):
self._fieldnames = value
def __next__(self):
if self.line_num == 0:
# Used only for its side effect.
self.fieldnames
row = next(self.reader)
self.line_num = self.reader.line_num
# unlike the basic reader, we prefer not to return blanks,
# because we will typically wind up with a dict full of None
# values
while row == []:
row = next(self.reader)
d = dict(zip(self.fieldnames, row))
lf = len(self.fieldnames)
lr = len(row)
if lf < lr:
d[self.restkey] = row[lf:]
elif lf > lr:
for key in self.fieldnames[lr:]:
d[key] = self.restval
return d
next = __next__
class DictWriter(object):
def __init__(self, f, fieldnames, restval="", extrasaction="raise",
dialect="excel", *args, **kwds):
self.fieldnames = fieldnames # list of keys for the dict
self.restval = restval # for writing short dicts
if extrasaction.lower() not in ("raise", "ignore"):
raise ValueError("extrasaction (%s) must be 'raise' or 'ignore'"
% extrasaction)
self.extrasaction = extrasaction
self.writer = writer(f, dialect, *args, **kwds)
def writeheader(self):
header = dict(zip(self.fieldnames, self.fieldnames))
self.writerow(header)
def _dict_to_list(self, rowdict):
if self.extrasaction == "raise":
wrong_fields = [k for k in rowdict if k not in self.fieldnames]
if wrong_fields:
raise ValueError("dict contains fields not in fieldnames: "
+ ", ".join([repr(x) for x in wrong_fields]))
return (rowdict.get(key, self.restval) for key in self.fieldnames)
def writerow(self, rowdict):
return self.writer.writerow(self._dict_to_list(rowdict))
def writerows(self, rowdicts):
return self.writer.writerows(map(self._dict_to_list, rowdicts))
# Guard Sniffer's type checking against builds that exclude complex()
try:
complex
except NameError:
complex = float
class Sniffer(object):
'''
"Sniffs" the format of a CSV file (i.e. delimiter, quotechar)
Returns a Dialect object.
'''
def __init__(self):
# in case there is more than one possible delimiter
self.preferred = [',', '\t', ';', ' ', ':']
def sniff(self, sample, delimiters=None):
"""
Returns a dialect (or None) corresponding to the sample
"""
quotechar, doublequote, delimiter, skipinitialspace = \
self._guess_quote_and_delimiter(sample, delimiters)
if not delimiter:
delimiter, skipinitialspace = self._guess_delimiter(sample,
delimiters)
if not delimiter:
raise Error("Could not determine delimiter")
class dialect(Dialect):
_name = "sniffed"
lineterminator = '\r\n'
quoting = QUOTE_MINIMAL
# escapechar = ''
dialect.doublequote = doublequote
dialect.delimiter = delimiter
# _csv.reader won't accept a quotechar of ''
dialect.quotechar = quotechar or '"'
dialect.skipinitialspace = skipinitialspace
return dialect
def _guess_quote_and_delimiter(self, data, delimiters):
"""
Looks for text enclosed between two identical quotes
(the probable quotechar) which are preceded and followed
by the same character (the probable delimiter).
For example:
,'some text',
The quote with the most wins, same with the delimiter.
If there is no quotechar the delimiter can't be determined
this way.
"""
matches = []
for restr in ('(?P<delim>[^\w\n"\'])(?P<space> ?)(?P<quote>["\']).*?(?P=quote)(?P=delim)', # ,".*?",
'(?:^|\n)(?P<quote>["\']).*?(?P=quote)(?P<delim>[^\w\n"\'])(?P<space> ?)', # ".*?",
'(?P<delim>>[^\w\n"\'])(?P<space> ?)(?P<quote>["\']).*?(?P=quote)(?:$|\n)', # ,".*?"
'(?:^|\n)(?P<quote>["\']).*?(?P=quote)(?:$|\n)'): # ".*?" (no delim, no space)
regexp = re.compile(restr, re.DOTALL | re.MULTILINE)
matches = regexp.findall(data)
if matches:
break
if not matches:
# (quotechar, doublequote, delimiter, skipinitialspace)
return ('', False, None, 0)
quotes = {}
delims = {}
spaces = 0
groupindex = regexp.groupindex
for m in matches:
n = groupindex['quote'] - 1
key = m[n]
if key:
quotes[key] = quotes.get(key, 0) + 1
try:
n = groupindex['delim'] - 1
key = m[n]
except KeyError:
continue
if key and (delimiters is None or key in delimiters):
delims[key] = delims.get(key, 0) + 1
try:
n = groupindex['space'] - 1
except KeyError:
continue
if m[n]:
spaces += 1
quotechar = max(quotes, key=quotes.get)
if delims:
delim = max(delims, key=delims.get)
skipinitialspace = delims[delim] == spaces
if delim == '\n': # most likely a file with a single column
delim = ''
else:
# there is *no* delimiter, it's a single column of quoted data
delim = ''
skipinitialspace = 0
# if we see an extra quote between delimiters, we've got a
# double quoted format
dq_regexp = re.compile(
r"((%(delim)s)|^)\W*%(quote)s[^%(delim)s\n]*%(quote)s[^%(delim)s\n]*%(quote)s\W*((%(delim)s)|$)" % \
{'delim':re.escape(delim), 'quote':quotechar}, re.MULTILINE)
if dq_regexp.search(data):
doublequote = True
else:
doublequote = False
return (quotechar, doublequote, delim, skipinitialspace)
def _guess_delimiter(self, data, delimiters):
"""
The delimiter /should/ occur the same number of times on
each row. However, due to malformed data, it may not. We don't want
an all or nothing approach, so we allow for small variations in this
number.
1) build a table of the frequency of each character on every line.
2) build a table of frequencies of this frequency (meta-frequency?),
e.g. 'x occurred 5 times in 10 rows, 6 times in 1000 rows,
7 times in 2 rows'
3) use the mode of the meta-frequency to determine the /expected/
frequency for that character
4) find out how often the character actually meets that goal
5) the character that best meets its goal is the delimiter
For performance reasons, the data is evaluated in chunks, so it can
try and evaluate the smallest portion of the data possible, evaluating
additional chunks as necessary.
"""
data = list(filter(None, data.split('\n')))
ascii = [unichr(c) for c in range(127)] # 7-bit ASCII
# build frequency tables
chunkLength = min(10, len(data))
iteration = 0
charFrequency = {}
modes = {}
delims = {}
start, end = 0, min(chunkLength, len(data))
while start < len(data):
iteration += 1
for line in data[start:end]:
for char in ascii:
metaFrequency = charFrequency.get(char, {})
# must count even if frequency is 0
freq = line.count(char)
# value is the mode
metaFrequency[freq] = metaFrequency.get(freq, 0) + 1
charFrequency[char] = metaFrequency
for char in charFrequency.keys():
items = list(charFrequency[char].items())
if len(items) == 1 and items[0][0] == 0:
continue
# get the mode of the frequencies
if len(items) > 1:
modes[char] = max(items, key=lambda x: x[1])
# adjust the mode - subtract the sum of all
# other frequencies
items.remove(modes[char])
modes[char] = (modes[char][0], modes[char][1]
- sum(item[1] for item in items))
else:
modes[char] = items[0]
# build a list of possible delimiters
modeList = modes.items()
total = float(chunkLength * iteration)
# (rows of consistent data) / (number of rows) = 100%
consistency = 1.0
# minimum consistency threshold
threshold = 0.9
while len(delims) == 0 and consistency >= threshold:
for k, v in modeList:
if v[0] > 0 and v[1] > 0:
if ((v[1]/total) >= consistency and
(delimiters is None or k in delimiters)):
delims[k] = v
consistency -= 0.01
if len(delims) == 1:
delim = list(delims.keys())[0]
skipinitialspace = (data[0].count(delim) ==
data[0].count("%c " % delim))
return (delim, skipinitialspace)
# analyze another chunkLength lines
start = end
end += chunkLength
if not delims:
return ('', 0)
# if there's more than one, fall back to a 'preferred' list
if len(delims) > 1:
for d in self.preferred:
if d in delims.keys():
skipinitialspace = (data[0].count(d) ==
data[0].count("%c " % d))
return (d, skipinitialspace)
# nothing else indicates a preference, pick the character that
# dominates(?)
items = [(v,k) for (k,v) in delims.items()]
items.sort()
delim = items[-1][1]
skipinitialspace = (data[0].count(delim) ==
data[0].count("%c " % delim))
return (delim, skipinitialspace)
def has_header(self, sample):
# Creates a dictionary of types of data in each column. If any
# column is of a single type (say, integers), *except* for the first
# row, then the first row is presumed to be labels. If the type
# can't be determined, it is assumed to be a string in which case
# the length of the string is the determining factor: if all of the
# rows except for the first are the same length, it's a header.
# Finally, a 'vote' is taken at the end for each column, adding or
# subtracting from the likelihood of the first row being a header.
rdr = reader(StringIO(sample), self.sniff(sample))
header = next(rdr) # assume first row is header
columns = len(header)
columnTypes = {}
for i in range(columns): columnTypes[i] = None
checked = 0
for row in rdr:
# arbitrary number of rows to check, to keep it sane
if checked > 20:
break
checked += 1
if len(row) != columns:
continue # skip rows that have irregular number of columns
for col in list(columnTypes.keys()):
for thisType in [int, float, complex]:
try:
thisType(row[col])
break
except (ValueError, OverflowError):
pass
else:
# fallback to length of string
thisType = len(row[col])
if thisType != columnTypes[col]:
if columnTypes[col] is None: # add new column type
columnTypes[col] = thisType
else:
# type is inconsistent, remove column from
# consideration
del columnTypes[col]
# finally, compare results against first row and "vote"
# on whether it's a header
hasHeader = 0
for col, colType in columnTypes.items():
if type(colType) == type(0): # it's a length
if len(header[col]) != colType:
hasHeader += 1
else:
hasHeader -= 1
else: # attempt typecast
try:
colType(header[col])
except (ValueError, TypeError):
hasHeader += 1
else:
hasHeader -= 1
return hasHeader > 0

View file

@ -36,6 +36,8 @@ class Audio(PlexPartialObject):
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = data.attrib.get('librarySectionID') self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary') self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb') self.thumb = data.attrib.get('thumb')
@ -120,17 +122,26 @@ class Artist(Audio):
TAG = 'Directory' TAG = 'Directory'
TYPE = 'artist' TYPE = 'artist'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeMarkers=1&includeConcerts=1&includePreferences=1'
'&indcludeBandwidths=1&includeLoudnessRamps=1')
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
Audio._loadData(self, data) Audio._loadData(self, data)
self._details_key = self.key + self._include
self.art = data.attrib.get('art') self.art = data.attrib.get('art')
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.key = self.key.replace('/children', '') # FIX_BUG_50 self.key = self.key.replace('/children', '') # FIX_BUG_50
self.locations = self.listAttrs(data, 'path', etag='Location') self.locations = self.listAttrs(data, 'path', etag='Location')
self.countries = self.findItems(data, media.Country) self.countries = self.findItems(data, media.Country)
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre) self.genres = self.findItems(data, media.Genre)
self.similar = self.findItems(data, media.Similar) self.similar = self.findItems(data, media.Similar)
self.collections = self.findItems(data, media.Collection) self.collections = self.findItems(data, media.Collection)
self.moods = self.findItems(data, media.Mood)
self.styles = self.findItems(data, media.Style)
def __iter__(self): def __iter__(self):
for album in self.albums(): for album in self.albums():
@ -217,17 +228,26 @@ class Album(Audio):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
Audio._loadData(self, data) Audio._loadData(self, data)
self.art = data.attrib.get('art') self.art = data.attrib.get('art')
self.guid = data.attrib.get('guid')
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion'))
self.key = self.key.replace('/children', '') # fixes bug #50 self.key = self.key.replace('/children', '') # fixes bug #50
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid')
self.parentKey = data.attrib.get('parentKey') self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = data.attrib.get('parentRatingKey') self.parentRatingKey = data.attrib.get('parentRatingKey')
self.parentThumb = data.attrib.get('parentThumb') self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle') self.parentTitle = data.attrib.get('parentTitle')
self.rating = utils.cast(float, data.attrib.get('rating'))
self.studio = data.attrib.get('studio') self.studio = data.attrib.get('studio')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
self.genres = self.findItems(data, media.Genre)
self.collections = self.findItems(data, media.Collection) self.collections = self.findItems(data, media.Collection)
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.labels = self.findItems(data, media.Label) self.labels = self.findItems(data, media.Label)
self.moods = self.findItems(data, media.Mood)
self.styles = self.findItems(data, media.Style)
def track(self, title): def track(self, title):
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title. """ Returns the :class:`~plexapi.audio.Track` that matches the specified title.
@ -312,20 +332,28 @@ class Track(Audio, Playable):
TAG = 'Track' TAG = 'Track'
TYPE = 'track' TYPE = 'track'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeMarkers=1&includeConcerts=1&includePreferences=1'
'&indcludeBandwidths=1&includeLoudnessRamps=1')
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
Audio._loadData(self, data) Audio._loadData(self, data)
Playable._loadData(self, data) Playable._loadData(self, data)
self._details_key = self.key + self._include
self.art = data.attrib.get('art') self.art = data.attrib.get('art')
self.chapterSource = data.attrib.get('chapterSource') self.chapterSource = data.attrib.get('chapterSource')
self.duration = utils.cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentGuid = data.attrib.get('grandparentGuid')
self.grandparentKey = data.attrib.get('grandparentKey') self.grandparentKey = data.attrib.get('grandparentKey')
self.grandparentRatingKey = data.attrib.get('grandparentRatingKey') self.grandparentRatingKey = data.attrib.get('grandparentRatingKey')
self.grandparentThumb = data.attrib.get('grandparentThumb') self.grandparentThumb = data.attrib.get('grandparentThumb')
self.grandparentTitle = data.attrib.get('grandparentTitle') self.grandparentTitle = data.attrib.get('grandparentTitle')
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.originalTitle = data.attrib.get('originalTitle') self.originalTitle = data.attrib.get('originalTitle')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = data.attrib.get('parentIndex') self.parentIndex = data.attrib.get('parentIndex')
self.parentKey = data.attrib.get('parentKey') self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = data.attrib.get('parentRatingKey') self.parentRatingKey = data.attrib.get('parentRatingKey')
@ -338,6 +366,7 @@ class Track(Audio, Playable):
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
self.media = self.findItems(data, media.Media) self.media = self.findItems(data, media.Media)
self.moods = self.findItems(data, media.Mood) self.moods = self.findItems(data, media.Mood)
self.fields = self.findItems(data, media.Field)
def _prettyfilename(self): def _prettyfilename(self):
""" Returns a filename for use in download. """ """ Returns a filename for use in download. """
@ -351,6 +380,13 @@ class Track(Audio, Playable):
""" Return this track's :class:`~plexapi.audio.Artist`. """ """ Return this track's :class:`~plexapi.audio.Artist`. """
return self.fetchItem(self.grandparentKey) return self.fetchItem(self.grandparentKey)
@property
def locations(self):
""" This does not exist in plex xml response but is added to have a common
interface to get the location of the Artist
"""
return [part.file for part in self.iterParts() if part]
def _defaultSyncTitle(self): def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """ """ Returns str, default title for a new syncItem. """
return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title) return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title)

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from plexapi import X_PLEX_CONTAINER_SIZE, log, utils from plexapi import X_PLEX_CONTAINER_SIZE, log, utils, media
from plexapi.base import PlexObject from plexapi.base import PlexObject
from plexapi.compat import quote, quote_plus, unquote, urlencode from plexapi.compat import quote, quote_plus, unquote, urlencode
from plexapi.exceptions import BadRequest, NotFound from plexapi.exceptions import BadRequest, NotFound
@ -769,6 +769,11 @@ class MovieSection(LibrarySection):
""" Returns a list of collections from this library section. """ """ Returns a list of collections from this library section. """
return self.search(libtype='collection', **kwargs) return self.search(libtype='collection', **kwargs)
def playlist(self, **kwargs):
""" Returns a list of playlists from this library section. """
key = '/playlists?type=15&playlistType=%s&sectionID=%s' % (self.CONTENT_TYPE, self.key)
return self.fetchItems(key)
def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): def sync(self, videoQuality, limit=None, unwatched=False, **kwargs):
""" Add current Movie library section as sync item for specified device. """ Add current Movie library section as sync item for specified device.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
@ -849,6 +854,11 @@ class ShowSection(LibrarySection):
""" Returns a list of collections from this library section. """ """ Returns a list of collections from this library section. """
return self.search(libtype='collection', **kwargs) return self.search(libtype='collection', **kwargs)
def playlist(self, **kwargs):
""" Returns a list of playlists from this library section. """
key = '/playlists?type=15&playlistType=%s&sectionID=%s' % (self.CONTENT_TYPE, self.key)
return self.fetchItems(key)
def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): def sync(self, videoQuality, limit=None, unwatched=False, **kwargs):
""" Add current Show library section as sync item for specified device. """ Add current Show library section as sync item for specified device.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
@ -930,6 +940,11 @@ class MusicSection(LibrarySection):
""" Returns a list of collections from this library section. """ """ Returns a list of collections from this library section. """
return self.search(libtype='collection', **kwargs) return self.search(libtype='collection', **kwargs)
def playlist(self, **kwargs):
""" Returns a list of playlists from this library section. """
key = '/playlists?type=15&playlistType=%s&sectionID=%s' % (self.CONTENT_TYPE, self.key)
return self.fetchItems(key)
def sync(self, bitrate, limit=None, **kwargs): def sync(self, bitrate, limit=None, **kwargs):
""" Add current Music library section as sync item for specified device. """ Add current Music library section as sync item for specified device.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
@ -991,6 +1006,11 @@ class PhotoSection(LibrarySection):
""" Search for a photo. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ """ Search for a photo. See :func:`~plexapi.library.LibrarySection.search()` for usage. """
return self.search(libtype='photo', title=title, **kwargs) return self.search(libtype='photo', title=title, **kwargs)
def playlist(self, **kwargs):
""" Returns a list of playlists from this library section. """
key = '/playlists?type=15&playlistType=%s&sectionID=%s' % (self.CONTENT_TYPE, self.key)
return self.fetchItems(key)
def sync(self, resolution, limit=None, **kwargs): def sync(self, resolution, limit=None, **kwargs):
""" Add current Music library section as sync item for specified device. """ Add current Music library section as sync item for specified device.
See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting and
@ -1092,9 +1112,16 @@ class Collections(PlexObject):
def _loadData(self, data): def _loadData(self, data):
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self._details_key = "/library/metadata/%s%s" % (self.ratingKey, self._include) self._details_key = "/library/metadata/%s%s" % (self.ratingKey, self._include)
self.art = data.attrib.get('art')
self.contentRating = data.attrib.get('contentRating')
self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort')
self.subtype = data.attrib.get('subtype') self.subtype = data.attrib.get('subtype')
self.summary = data.attrib.get('summary') self.summary = data.attrib.get('summary')
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
@ -1106,11 +1133,23 @@ class Collections(PlexObject):
self.maxYear = utils.cast(int, data.attrib.get('maxYear')) self.maxYear = utils.cast(int, data.attrib.get('maxYear'))
self.collectionMode = data.attrib.get('collectionMode') self.collectionMode = data.attrib.get('collectionMode')
self.collectionSort = data.attrib.get('collectionSort') self.collectionSort = data.attrib.get('collectionSort')
self.labels = self.findItems(data, media.Label)
self.fields = self.findItems(data, media.Field)
@property @property
def children(self): def children(self):
return self.fetchItems(self.key) return self.fetchItems(self.key)
@property
def thumbUrl(self):
""" Return the thumbnail url for the collection."""
return self._server.url(self.thumb, includeToken=True) if self.thumb else None
@property
def artUrl(self):
""" Return the art url for the collection."""
return self._server.url(self.art, includeToken=True) if self.art else None
def __len__(self): def __len__(self):
return self.childCount return self.childCount

View file

@ -5,7 +5,7 @@ import xml
from plexapi import compat, log, settings, utils from plexapi import compat, log, settings, utils
from plexapi.base import PlexObject from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest from plexapi.exceptions import BadRequest
from plexapi.utils import cast from plexapi.utils import cast, SEARCHTYPES
@utils.registerPlexObject @utils.registerPlexObject
@ -45,6 +45,7 @@ class Media(PlexObject):
self.aspectRatio = cast(float, data.attrib.get('aspectRatio')) self.aspectRatio = cast(float, data.attrib.get('aspectRatio'))
self.audioChannels = cast(int, data.attrib.get('audioChannels')) self.audioChannels = cast(int, data.attrib.get('audioChannels'))
self.audioCodec = data.attrib.get('audioCodec') self.audioCodec = data.attrib.get('audioCodec')
self.audioProfile = data.attrib.get('videoProfile')
self.bitrate = cast(int, data.attrib.get('bitrate')) self.bitrate = cast(int, data.attrib.get('bitrate'))
self.container = data.attrib.get('container') self.container = data.attrib.get('container')
self.duration = cast(int, data.attrib.get('duration')) self.duration = cast(int, data.attrib.get('duration'))
@ -60,6 +61,16 @@ class Media(PlexObject):
self.videoResolution = data.attrib.get('videoResolution') self.videoResolution = data.attrib.get('videoResolution')
self.width = cast(int, data.attrib.get('width')) self.width = cast(int, data.attrib.get('width'))
self.parts = self.findItems(data, MediaPart) self.parts = self.findItems(data, MediaPart)
self.proxyType = cast(int, data.attrib.get('proxyType'))
self.optimizedVersion = self.proxyType == SEARCHTYPES['optimizedVersion']
# For Photo only
self.aperture = data.attrib.get('aperture')
self.exposure = data.attrib.get('exposure')
self.iso = cast(int, data.attrib.get('iso'))
self.lens = data.attrib.get('lens')
self.make = data.attrib.get('make')
self.model = data.attrib.get('model')
def delete(self): def delete(self):
part = self._initpath + '/media/%s' % self.id part = self._initpath + '/media/%s' % self.id
@ -96,15 +107,20 @@ class MediaPart(PlexObject):
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
self._data = data self._data = data
self.audioProfile = data.attrib.get('audioProfile')
self.container = data.attrib.get('container') self.container = data.attrib.get('container')
self.deepAnalysisVersion = cast(int, data.attrib.get('deepAnalysisVersion'))
self.duration = cast(int, data.attrib.get('duration')) self.duration = cast(int, data.attrib.get('duration'))
self.file = data.attrib.get('file') self.file = data.attrib.get('file')
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets'))
self.hasThumbnail = cast(bool, data.attrib.get('hasThumbnail'))
self.id = cast(int, data.attrib.get('id')) self.id = cast(int, data.attrib.get('id'))
self.indexes = data.attrib.get('indexes') self.indexes = data.attrib.get('indexes')
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.size = cast(int, data.attrib.get('size')) self.size = cast(int, data.attrib.get('size'))
self.decision = data.attrib.get('decision') self.decision = data.attrib.get('decision')
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming')) self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming'))
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.syncItemId = cast(int, data.attrib.get('syncItemId')) self.syncItemId = cast(int, data.attrib.get('syncItemId'))
self.syncState = data.attrib.get('syncState') self.syncState = data.attrib.get('syncState')
self.videoProfile = data.attrib.get('videoProfile') self.videoProfile = data.attrib.get('videoProfile')
@ -112,10 +128,13 @@ class MediaPart(PlexObject):
self.exists = cast(bool, data.attrib.get('exists')) self.exists = cast(bool, data.attrib.get('exists'))
self.accessible = cast(bool, data.attrib.get('accessible')) self.accessible = cast(bool, data.attrib.get('accessible'))
# For Photo only
self.orientation = cast(int, data.attrib.get('orientation'))
def _buildStreams(self, data): def _buildStreams(self, data):
streams = [] streams = []
for elem in data: for elem in data:
for cls in (VideoStream, AudioStream, SubtitleStream): for cls in (VideoStream, AudioStream, SubtitleStream, LyricStream):
if elem.attrib.get('streamType') == str(cls.STREAMTYPE): if elem.attrib.get('streamType') == str(cls.STREAMTYPE):
streams.append(cls(self._server, elem, self._initpath)) streams.append(cls(self._server, elem, self._initpath))
return streams return streams
@ -132,6 +151,10 @@ class MediaPart(PlexObject):
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """ """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """
return [stream for stream in self.streams if stream.streamType == SubtitleStream.STREAMTYPE] return [stream for stream in self.streams if stream.streamType == SubtitleStream.STREAMTYPE]
def lyricStreams(self):
""" Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """
return [stream for stream in self.streams if stream.streamType == LyricStream.STREAMTYPE]
def setDefaultAudioStream(self, stream): def setDefaultAudioStream(self, stream):
""" Set the default :class:`~plexapi.media.AudioStream` for this MediaPart. """ Set the default :class:`~plexapi.media.AudioStream` for this MediaPart.
@ -177,7 +200,8 @@ class MediaPartStream(PlexObject):
languageCode (str): Ascii code for language (ex: eng, tha). languageCode (str): Ascii code for language (ex: eng, tha).
selected (bool): True if this stream is selected. selected (bool): True if this stream is selected.
streamType (int): Stream type (1=:class:`~plexapi.media.VideoStream`, streamType (int): Stream type (1=:class:`~plexapi.media.VideoStream`,
2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`). 2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`,
4=:class:`~plexapi.media.LyricStream`).
type (int): Alias for streamType. type (int): Alias for streamType.
""" """
@ -186,18 +210,22 @@ class MediaPartStream(PlexObject):
self._data = data self._data = data
self.codec = data.attrib.get('codec') self.codec = data.attrib.get('codec')
self.codecID = data.attrib.get('codecID') self.codecID = data.attrib.get('codecID')
self.default = cast(bool, data.attrib.get('selected', '0'))
self.displayTitle = data.attrib.get('displayTitle')
self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle')
self.id = cast(int, data.attrib.get('id')) self.id = cast(int, data.attrib.get('id'))
self.index = cast(int, data.attrib.get('index', '-1')) self.index = cast(int, data.attrib.get('index', '-1'))
self.language = data.attrib.get('language') self.language = data.attrib.get('language')
self.languageCode = data.attrib.get('languageCode') self.languageCode = data.attrib.get('languageCode')
self.selected = cast(bool, data.attrib.get('selected', '0')) self.selected = cast(bool, data.attrib.get('selected', '0'))
self.streamType = cast(int, data.attrib.get('streamType')) self.streamType = cast(int, data.attrib.get('streamType'))
self.title = data.attrib.get('title')
self.type = cast(int, data.attrib.get('streamType')) self.type = cast(int, data.attrib.get('streamType'))
@staticmethod @staticmethod
def parse(server, data, initpath): # pragma: no cover seems to be dead code. def parse(server, data, initpath): # pragma: no cover seems to be dead code.
""" Factory method returns a new MediaPartStream from xml data. """ """ Factory method returns a new MediaPartStream from xml data. """
STREAMCLS = {1: VideoStream, 2: AudioStream, 3: SubtitleStream} STREAMCLS = {1: VideoStream, 2: AudioStream, 3: SubtitleStream, 4: LyricStream}
stype = cast(int, data.attrib.get('streamType')) stype = cast(int, data.attrib.get('streamType'))
cls = STREAMCLS.get(stype, MediaPartStream) cls = STREAMCLS.get(stype, MediaPartStream)
return cls(server, data, initpath) return cls(server, data, initpath)
@ -233,21 +261,31 @@ class VideoStream(MediaPartStream):
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
super(VideoStream, self)._loadData(data) super(VideoStream, self)._loadData(data)
self.anamorphic = data.attrib.get('anamorphic')
self.bitDepth = cast(int, data.attrib.get('bitDepth')) self.bitDepth = cast(int, data.attrib.get('bitDepth'))
self.bitrate = cast(int, data.attrib.get('bitrate')) self.bitrate = cast(int, data.attrib.get('bitrate'))
self.cabac = cast(int, data.attrib.get('cabac')) self.cabac = cast(int, data.attrib.get('cabac'))
self.chromaLocation = data.attrib.get('chromaLocation')
self.chromaSubsampling = data.attrib.get('chromaSubsampling') self.chromaSubsampling = data.attrib.get('chromaSubsampling')
self.codedHeight = data.attrib.get('codedHeight')
self.codedWidth = data.attrib.get('codedWidth')
self.colorPrimaries = data.attrib.get('colorPrimaries')
self.colorRange = data.attrib.get('colorRange')
self.colorSpace = data.attrib.get('colorSpace') self.colorSpace = data.attrib.get('colorSpace')
self.colorTrc = data.attrib.get('colorTrc')
self.duration = cast(int, data.attrib.get('duration')) self.duration = cast(int, data.attrib.get('duration'))
self.frameRate = cast(float, data.attrib.get('frameRate')) self.frameRate = cast(float, data.attrib.get('frameRate'))
self.frameRateMode = data.attrib.get('frameRateMode') self.frameRateMode = data.attrib.get('frameRateMode')
self.hasScallingMatrix = cast(bool, data.attrib.get('hasScallingMatrix')) self.hasScalingMatrix = cast(bool, data.attrib.get('hasScalingMatrix'))
self.height = cast(int, data.attrib.get('height')) self.height = cast(int, data.attrib.get('height'))
self.level = cast(int, data.attrib.get('level')) self.level = cast(int, data.attrib.get('level'))
self.profile = data.attrib.get('profile') self.profile = data.attrib.get('profile')
self.refFrames = cast(int, data.attrib.get('refFrames')) self.refFrames = cast(int, data.attrib.get('refFrames'))
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.pixelAspectRatio = data.attrib.get('pixelAspectRatio')
self.pixelFormat = data.attrib.get('pixelFormat')
self.scanType = data.attrib.get('scanType') self.scanType = data.attrib.get('scanType')
self.title = data.attrib.get('title') self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier'))
self.width = cast(int, data.attrib.get('width')) self.width = cast(int, data.attrib.get('width'))
@ -281,8 +319,20 @@ class AudioStream(MediaPartStream):
self.channels = cast(int, data.attrib.get('channels')) self.channels = cast(int, data.attrib.get('channels'))
self.dialogNorm = cast(int, data.attrib.get('dialogNorm')) self.dialogNorm = cast(int, data.attrib.get('dialogNorm'))
self.duration = cast(int, data.attrib.get('duration')) self.duration = cast(int, data.attrib.get('duration'))
self.profile = data.attrib.get('profile')
self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.samplingRate = cast(int, data.attrib.get('samplingRate')) self.samplingRate = cast(int, data.attrib.get('samplingRate'))
self.title = data.attrib.get('title')
# For Track only
self.albumGain = cast(float, data.attrib.get('albumGain'))
self.albumPeak = cast(float, data.attrib.get('albumPeak'))
self.albumRange = cast(float, data.attrib.get('albumRange'))
self.endRamp = data.attrib.get('endRamp')
self.gain = cast(float, data.attrib.get('gain'))
self.loudness = cast(float, data.attrib.get('loudness'))
self.lra = cast(float, data.attrib.get('lra'))
self.peak = cast(float, data.attrib.get('peak'))
self.startRamp = data.attrib.get('startRamp')
@utils.registerPlexObject @utils.registerPlexObject
@ -303,10 +353,36 @@ class SubtitleStream(MediaPartStream):
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
super(SubtitleStream, self)._loadData(data) super(SubtitleStream, self)._loadData(data)
self.container = data.attrib.get('container')
self.forced = cast(bool, data.attrib.get('forced', '0')) self.forced = cast(bool, data.attrib.get('forced', '0'))
self.format = data.attrib.get('format') self.format = data.attrib.get('format')
self.headerCompression = data.attrib.get('headerCompression')
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.title = data.attrib.get('title') self.requiredBandwidths = data.attrib.get('requiredBandwidths')
@utils.registerPlexObject
class LyricStream(MediaPartStream):
""" Respresents a lyric stream within a :class:`~plexapi.media.MediaPart`.
Attributes:
TAG (str): 'Stream'
STREAMTYPE (int): 4
format (str): Lyric format (ex: lrc).
key (str): Key of this subtitle stream (ex: /library/streams/212284).
title (str): Title of this lyric stream.
"""
TAG = 'Stream'
STREAMTYPE = 4
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
super(LyricStream, self)._loadData(data)
self.format = data.attrib.get('format')
self.key = data.attrib.get('key')
self.minLines = cast(int, data.attrib.get('minLines'))
self.provider = data.attrib.get('provider')
self.timed = cast(bool, data.attrib.get('timed', '0'))
@utils.registerPlexObject @utils.registerPlexObject
@ -510,6 +586,29 @@ class MediaTag(PlexObject):
return self.fetchItems(self.key) return self.fetchItems(self.key)
class GuidTag(PlexObject):
""" Base class for guid tags used only for Guids, as they contain only a string identifier
Attributes:
server (:class:`~plexapi.server.PlexServer`): Server this client is connected to.
id (id): Tag ID (Used as a unique id, except for Guid's, used for external systems
to plex identifiers, like imdb and tmdb).
"""
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.id = data.attrib.get('id')
self.tag = data.attrib.get('tag')
def items(self, *args, **kwargs):
""" Return the list of items within this tag. This function is only applicable
in search results from PlexServer :func:`~plexapi.server.PlexServer.search()`.
"""
if not self.key:
raise BadRequest('Key is not defined for this tag: %s' % self.tag)
return self.fetchItems(self.key)
@utils.registerPlexObject @utils.registerPlexObject
class Collection(MediaTag): class Collection(MediaTag):
""" Represents a single Collection media tag. """ Represents a single Collection media tag.
@ -589,6 +688,12 @@ class Genre(MediaTag):
FILTER = 'genre' FILTER = 'genre'
@utils.registerPlexObject
class Guid(GuidTag):
""" Represents a single Guid media tag. """
TAG = "Guid"
@utils.registerPlexObject @utils.registerPlexObject
class Mood(MediaTag): class Mood(MediaTag):
""" Represents a single Mood media tag. """ Represents a single Mood media tag.
@ -601,6 +706,18 @@ class Mood(MediaTag):
FILTER = 'mood' FILTER = 'mood'
@utils.registerPlexObject
class Style(MediaTag):
""" Represents a single Style media tag.
Attributes:
TAG (str): 'Style'
FILTER (str): 'style'
"""
TAG = 'Style'
FILTER = 'style'
@utils.registerPlexObject @utils.registerPlexObject
class Poster(PlexObject): class Poster(PlexObject):
""" Represents a Poster. """ Represents a Poster.
@ -689,6 +806,7 @@ class Chapter(PlexObject):
self.filter = data.attrib.get('filter') # I couldn't filter on it anyways self.filter = data.attrib.get('filter') # I couldn't filter on it anyways
self.tag = data.attrib.get('tag') self.tag = data.attrib.get('tag')
self.title = self.tag self.title = self.tag
self.thumb = data.attrib.get('thumb')
self.index = cast(int, data.attrib.get('index')) self.index = cast(int, data.attrib.get('index'))
self.start = cast(int, data.attrib.get('startTimeOffset')) self.start = cast(int, data.attrib.get('startTimeOffset'))
self.end = cast(int, data.attrib.get('endTimeOffset')) self.end = cast(int, data.attrib.get('endTimeOffset'))

View file

@ -40,12 +40,16 @@ class Photoalbum(PlexPartialObject):
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.librarySectionID = data.attrib.get('librarySectionID') self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.ratingKey = data.attrib.get('ratingKey') self.ratingKey = data.attrib.get('ratingKey')
self.summary = data.attrib.get('summary') self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb') self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.fields = self.findItems(data, media.Field)
def albums(self, **kwargs): def albums(self, **kwargs):
""" Returns a list of :class:`~plexapi.photo.Photoalbum` objects in this album. """ """ Returns a list of :class:`~plexapi.photo.Photoalbum` objects in this album. """
@ -99,25 +103,43 @@ class Photo(PlexPartialObject):
TYPE = 'photo' TYPE = 'photo'
METADATA_TYPE = 'photo' METADATA_TYPE = 'photo'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeMarkers=1&includeConcerts=1&includePreferences=1'
'&indcludeBandwidths=1&includeLoudnessRamps=1')
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
self.key = data.attrib.get('key')
self._details_key = self.key + self._include
self.listType = 'photo' self.listType = 'photo'
self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.createdAtAccuracy = data.attrib.get('createdAtAccuracy')
self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset'))
self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key') self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.originallyAvailableAt = utils.toDatetime( self.originallyAvailableAt = utils.toDatetime(
data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = utils.cast(int, data.attrib.get('parentIndex'))
self.parentKey = data.attrib.get('parentKey') self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = data.attrib.get('parentRatingKey') self.parentRatingKey = data.attrib.get('parentRatingKey')
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle')
self.ratingKey = data.attrib.get('ratingKey') self.ratingKey = data.attrib.get('ratingKey')
self.summary = data.attrib.get('summary') self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb') self.thumb = data.attrib.get('thumb')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
self.media = self.findItems(data, media.Media) self.media = self.findItems(data, media.Media)
self.tag = self.findItems(data, media.Tag) self.tag = self.findItems(data, media.Tag)
self.fields = self.findItems(data, media.Field)
def photoalbum(self): def photoalbum(self):
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """ """ Return this photo's :class:`~plexapi.photo.Photoalbum`. """

View file

@ -23,7 +23,8 @@ log = logging.getLogger('plexapi')
# Library Types - Populated at runtime # Library Types - Populated at runtime
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7, SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7,
'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14, 'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14,
'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'userPlaylistItem': 1001} 'playlist': 15, 'playlistFolder': 16, 'collection': 18,
'optimizedVersion': 42, 'userPlaylistItem': 1001}
PLEXOBJECTS = {} PLEXOBJECTS = {}

View file

@ -35,6 +35,8 @@ class Video(PlexPartialObject):
self.key = data.attrib.get('key', '') self.key = data.attrib.get('key', '')
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = data.attrib.get('librarySectionID') self.librarySectionID = data.attrib.get('librarySectionID')
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.summary = data.attrib.get('summary') self.summary = data.attrib.get('summary')
self.thumb = data.attrib.get('thumb') self.thumb = data.attrib.get('thumb')
@ -264,6 +266,7 @@ class Movie(Playable, Video):
directors (List<:class:`~plexapi.media.Director`>): List of director objects. directors (List<:class:`~plexapi.media.Director`>): List of director objects.
fields (List<:class:`~plexapi.media.Field`>): List of field objects. fields (List<:class:`~plexapi.media.Field`>): List of field objects.
genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. genres (List<:class:`~plexapi.media.Genre`>): List of genre objects.
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
media (List<:class:`~plexapi.media.Media`>): List of media objects. media (List<:class:`~plexapi.media.Media`>): List of media objects.
producers (List<:class:`~plexapi.media.Producer`>): List of producers objects. producers (List<:class:`~plexapi.media.Producer`>): List of producers objects.
roles (List<:class:`~plexapi.media.Role`>): List of role objects. roles (List<:class:`~plexapi.media.Role`>): List of role objects.
@ -276,7 +279,8 @@ class Movie(Playable, Video):
METADATA_TYPE = 'movie' METADATA_TYPE = 'movie'
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1' _include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1' '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeConcerts=1&includePreferences=1') '&includeConcerts=1&includePreferences=1'
'&indcludeBandwidths=1')
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
@ -307,6 +311,7 @@ class Movie(Playable, Video):
self.directors = self.findItems(data, media.Director) self.directors = self.findItems(data, media.Director)
self.fields = self.findItems(data, media.Field) self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre) self.genres = self.findItems(data, media.Genre)
self.guids = self.findItems(data, media.Guid)
self.media = self.findItems(data, media.Media) self.media = self.findItems(data, media.Media)
self.producers = self.findItems(data, media.Producer) self.producers = self.findItems(data, media.Producer)
self.roles = self.findItems(data, media.Role) self.roles = self.findItems(data, media.Role)
@ -415,6 +420,7 @@ class Show(Video):
self.theme = data.attrib.get('theme') self.theme = data.attrib.get('theme')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre) self.genres = self.findItems(data, media.Genre)
self.roles = self.findItems(data, media.Role) self.roles = self.findItems(data, media.Role)
self.labels = self.findItems(data, media.Label) self.labels = self.findItems(data, media.Label)
@ -527,12 +533,19 @@ class Season(Video):
Video._loadData(self, data) Video._loadData(self, data)
# fix key if loaded from search # fix key if loaded from search
self.key = self.key.replace('/children', '') self.key = self.key.replace('/children', '')
self.art = data.attrib.get('art')
self.guid = data.attrib.get('guid')
self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = data.attrib.get('parentIndex')
self.parentKey = data.attrib.get('parentKey') self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentTheme = data.attrib.get('parentTheme')
self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle') self.parentTitle = data.attrib.get('parentTitle')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.fields = self.findItems(data, media.Field)
def __repr__(self): def __repr__(self):
return '<%s>' % ':'.join([p for p in [ return '<%s>' % ':'.join([p for p in [
@ -644,7 +657,8 @@ class Episode(Playable, Video):
_include = ('?checkFiles=1&includeExtras=1&includeRelated=1' _include = ('?checkFiles=1&includeExtras=1&includeRelated=1'
'&includeOnDeck=1&includeChapters=1&includePopularLeaves=1' '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1'
'&includeConcerts=1&includePreferences=1') '&includeMarkers=1&includeConcerts=1&includePreferences=1'
'&indcludeBandwidths=1')
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
@ -657,6 +671,7 @@ class Episode(Playable, Video):
self.contentRating = data.attrib.get('contentRating') self.contentRating = data.attrib.get('contentRating')
self.duration = utils.cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentGuid = data.attrib.get('grandparentGuid')
self.grandparentKey = data.attrib.get('grandparentKey') self.grandparentKey = data.attrib.get('grandparentKey')
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
self.grandparentTheme = data.attrib.get('grandparentTheme') self.grandparentTheme = data.attrib.get('grandparentTheme')
@ -665,6 +680,7 @@ class Episode(Playable, Video):
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d')
self.parentGuid = data.attrib.get('parentGuid')
self.parentIndex = data.attrib.get('parentIndex') self.parentIndex = data.attrib.get('parentIndex')
self.parentKey = data.attrib.get('parentKey') self.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
@ -675,6 +691,7 @@ class Episode(Playable, Video):
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
self.directors = self.findItems(data, media.Director) self.directors = self.findItems(data, media.Director)
self.fields = self.findItems(data, media.Field)
self.media = self.findItems(data, media.Media) self.media = self.findItems(data, media.Media)
self.writers = self.findItems(data, media.Writer) self.writers = self.findItems(data, media.Writer)
self.labels = self.findItems(data, media.Label) self.labels = self.findItems(data, media.Label)

View file

@ -45,7 +45,7 @@ if PYTHON2:
import common import common
import database import database
import datafactory import datafactory
import helpers import exporter
import libraries import libraries
import logger import logger
import mobile_app import mobile_app
@ -65,7 +65,7 @@ else:
from plexpy import common from plexpy import common
from plexpy import database from plexpy import database
from plexpy import datafactory from plexpy import datafactory
from plexpy import helpers from plexpy import exporter
from plexpy import libraries from plexpy import libraries
from plexpy import logger from plexpy import logger
from plexpy import mobile_app from plexpy import mobile_app
@ -226,6 +226,8 @@ def initialize(config_file):
CONFIG.BACKUP_DIR, os.path.join(DATA_DIR, 'backups'), 'backups') CONFIG.BACKUP_DIR, os.path.join(DATA_DIR, 'backups'), 'backups')
CONFIG.CACHE_DIR, _ = check_folder_writable( CONFIG.CACHE_DIR, _ = check_folder_writable(
CONFIG.CACHE_DIR, os.path.join(DATA_DIR, 'cache'), 'cache') CONFIG.CACHE_DIR, os.path.join(DATA_DIR, 'cache'), 'cache')
CONFIG.EXPORT_DIR, _ = check_folder_writable(
CONFIG.EXPORT_DIR, os.path.join(DATA_DIR, 'exports'), 'exports')
CONFIG.NEWSLETTER_DIR, _ = check_folder_writable( CONFIG.NEWSLETTER_DIR, _ = check_folder_writable(
CONFIG.NEWSLETTER_DIR, os.path.join(DATA_DIR, 'newsletters'), 'newsletters') CONFIG.NEWSLETTER_DIR, os.path.join(DATA_DIR, 'newsletters'), 'newsletters')
@ -533,6 +535,9 @@ def start():
notification_handler.start_threads(num_threads=CONFIG.NOTIFICATION_THREADS) notification_handler.start_threads(num_threads=CONFIG.NOTIFICATION_THREADS)
notifiers.check_browser_enabled() notifiers.check_browser_enabled()
# Cancel processing exports
exporter.cancel_exports()
if CONFIG.FIRST_RUN_COMPLETE: if CONFIG.FIRST_RUN_COMPLETE:
activity_pinger.connect_server(log=True, startup=True) activity_pinger.connect_server(log=True, startup=True)
@ -789,6 +794,17 @@ def dbcheck():
'img_hash TEXT, cloudinary_title TEXT, cloudinary_url TEXT)' 'img_hash TEXT, cloudinary_title TEXT, cloudinary_url TEXT)'
) )
# exports table :: This table keeps record of the exported files
c_db.execute(
'CREATE TABLE IF NOT EXISTS exports (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'timestamp INTEGER, section_id INTEGER, user_id INTEGER, rating_key INTEGER, media_type TEXT, '
'filename TEXT, file_format TEXT, '
'metadata_level INTEGER, media_info_level INTEGER, '
'include_thumb INTEGER DEFAULT 0, include_art INTEGER DEFAULT 0, '
'custom_fields TEXT, '
'file_size INTEGER DEFAULT 0, complete INTEGER DEFAULT 0)'
)
# Upgrade sessions table from earlier versions # Upgrade sessions table from earlier versions
try: try:
c_db.execute('SELECT started FROM sessions') c_db.execute('SELECT started FROM sessions')

View file

@ -74,6 +74,9 @@ MEDIA_TYPE_HEADERS = {
'artist': 'Artists', 'artist': 'Artists',
'album': 'Albums', 'album': 'Albums',
'track': 'Tracks', 'track': 'Tracks',
'video': 'Videos',
'audio': 'Tracks',
'photo': 'Photos'
} }
PLATFORM_NAME_OVERRIDES = { PLATFORM_NAME_OVERRIDES = {

View file

@ -96,6 +96,7 @@ _CONFIG_DEFINITIONS = {
'CONFIG_VERSION': (int, 'Advanced', 0), 'CONFIG_VERSION': (int, 'Advanced', 0),
'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0), 'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0),
'ENABLE_HTTPS': (int, 'General', 0), 'ENABLE_HTTPS': (int, 'General', 0),
'EXPORT_DIR': (str, 'General', ''),
'FIRST_RUN_COMPLETE': (int, 'General', 0), 'FIRST_RUN_COMPLETE': (int, 'General', 0),
'FREEZE_DB': (int, 'General', 0), 'FREEZE_DB': (int, 'General', 0),
'GET_FILE_SIZES': (int, 'General', 0), 'GET_FILE_SIZES': (int, 'General', 0),
@ -195,7 +196,7 @@ _WHITELIST_KEYS = ['HTTPS_KEY']
_DO_NOT_IMPORT_KEYS = [ _DO_NOT_IMPORT_KEYS = [
'FIRST_RUN_COMPLETE', 'GET_FILE_SIZES_HOLD', 'GIT_PATH', 'PMS_LOGS_FOLDER', 'FIRST_RUN_COMPLETE', 'GET_FILE_SIZES_HOLD', 'GIT_PATH', 'PMS_LOGS_FOLDER',
'BACKUP_DIR', 'CACHE_DIR', 'LOG_DIR', 'NEWSLETTER_DIR', 'NEWSLETTER_CUSTOM_DIR', 'BACKUP_DIR', 'CACHE_DIR', 'EXPORT_DIR', 'LOG_DIR', 'NEWSLETTER_DIR', 'NEWSLETTER_CUSTOM_DIR',
'HTTP_HOST', 'HTTP_PORT', 'HTTP_ROOT', 'HTTP_HOST', 'HTTP_PORT', 'HTTP_ROOT',
'HTTP_USERNAME', 'HTTP_PASSWORD', 'HTTP_HASH_PASSWORD', 'HTTP_HASHED_PASSWORD', 'HTTP_USERNAME', 'HTTP_PASSWORD', 'HTTP_HASH_PASSWORD', 'HTTP_HASHED_PASSWORD',
'ENABLE_HTTPS', 'HTTPS_CREATE_CERT', 'HTTPS_CERT', 'HTTPS_CERT_CHAIN', 'HTTPS_KEY' 'ENABLE_HTTPS', 'HTTPS_CREATE_CERT', 'HTTPS_CERT', 'HTTPS_CERT_CHAIN', 'HTTPS_KEY'

View file

@ -216,6 +216,11 @@ def delete_recently_added():
return clear_table('recently_added') return clear_table('recently_added')
def delete_exports():
logger.info("Tautulli Database :: Clearing exported items from database.")
return clear_table('exports')
def delete_rows_from_table(table, row_ids): def delete_rows_from_table(table, row_ids):
if row_ids and isinstance(row_ids, str): if row_ids and isinstance(row_ids, str):
row_ids = list(map(helpers.cast_to_int, row_ids.split(','))) row_ids = list(map(helpers.cast_to_int, row_ids.split(',')))

View file

@ -290,8 +290,8 @@ class DataFactory(object):
'recordsTotal': query['totalCount'], 'recordsTotal': query['totalCount'],
'data': session.friendly_name_to_username(rows), 'data': session.friendly_name_to_username(rows),
'draw': query['draw'], 'draw': query['draw'],
'filter_duration': helpers.human_duration(filter_duration, sig='dhm'), 'filter_duration': helpers.human_duration(filter_duration, sig='dhm', units='s'),
'total_duration': helpers.human_duration(total_duration, sig='dhm') 'total_duration': helpers.human_duration(total_duration, sig='dhm', units='s')
} }
return dict return dict

2040
plexpy/exporter.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# This file is part of Tautulli. # This file is part of Tautulli.
# #
@ -28,7 +28,7 @@ from cloudinary.api import delete_resources_by_tag
from cloudinary.uploader import upload from cloudinary.uploader import upload
from cloudinary.utils import cloudinary_url from cloudinary.utils import cloudinary_url
import datetime import datetime
from functools import wraps from functools import reduce, wraps
import hashlib import hashlib
import imghdr import imghdr
from future.moves.itertools import islice, zip_longest from future.moves.itertools import islice, zip_longest
@ -38,6 +38,7 @@ import ipwhois.utils
from IPy import IP from IPy import IP
import json import json
import math import math
import operator
import os import os
import re import re
import shlex import shlex
@ -242,30 +243,44 @@ def iso_to_datetime(iso):
return arrow.get(iso).datetime return arrow.get(iso).datetime
def human_duration(s, sig='dhms'): def datetime_to_iso(dt, to_date=False):
if isinstance(dt, datetime.datetime):
if to_date:
dt = dt.date()
return dt.isoformat()
return dt
hd = ''
if str(s).isdigit() and s > 0: def human_duration(ms, sig='dhms', units='ms'):
d, h = divmod(s, 86400) factors = {'d': 86400000,
h, m = divmod(h, 3600) 'h': 3600000,
m, s = divmod(m, 60) 'm': 60000,
's': 1000,
'ms': 1}
if str(ms).isdigit() and ms > 0:
ms = ms * factors[units]
d, h = divmod(ms, factors['d'])
h, m = divmod(h, factors['h'])
m, s = divmod(m, factors['m'])
s, ms = divmod(s, factors['s'])
hd_list = [] hd_list = []
if sig >= 'd' and d > 0: if sig >= 'd' and d > 0:
d = d + 1 if sig == 'd' and h >= 12 else d d = d + 1 if sig == 'd' and h >= 12 else d
hd_list.append(str(d) + ' days') hd_list.append(str(d) + ' day' + ('s' if d > 1 else ''))
if sig >= 'dh' and h > 0: if sig >= 'dh' and h > 0:
h = h + 1 if sig == 'dh' and m >= 30 else h h = h + 1 if sig == 'dh' and m >= 30 else h
hd_list.append(str(h) + ' hrs') hd_list.append(str(h) + ' hr' + ('s' if h > 1 else ''))
if sig >= 'dhm' and m > 0: if sig >= 'dhm' and m > 0:
m = m + 1 if sig == 'dhm' and s >= 30 else m m = m + 1 if sig == 'dhm' and s >= 30 else m
hd_list.append(str(m) + ' mins') hd_list.append(str(m) + ' min' + ('s' if m > 1 else ''))
if sig >= 'dhms' and s > 0: if sig >= 'dhms' and s > 0:
hd_list.append(str(s) + ' secs') hd_list.append(str(s) + ' sec' + ('s' if s > 1 else ''))
hd = ' '.join(hd_list) hd = ' '.join(hd_list)
else: else:
@ -382,6 +397,13 @@ def cleanTitle(title):
return title return title
def clean_filename(filename, replace='_'):
whitelist = "-_.()[] {}{}".format(string.ascii_letters, string.digits)
cleaned_filename = unicodedata.normalize('NFKD', filename).encode('ASCII', 'ignore').decode()
cleaned_filename = ''.join(c if c in whitelist else replace for c in cleaned_filename)
return cleaned_filename
def split_path(f): def split_path(f):
""" """
Split a path into components, starting with the drive letter (if any). Given Split a path into components, starting with the drive letter (if any). Given
@ -559,6 +581,64 @@ def process_json_kwargs(json_kwargs):
return params return params
def process_datatable_rows(rows, json_data, default_sort, search_cols=None, sort_keys=None):
if search_cols is None:
search_cols = []
if sort_keys is None:
sort_keys = {}
results = []
total_count = len(rows)
# Search results
search_value = json_data['search']['value'].lower()
if search_value:
searchable_columns = [d['data'] for d in json_data['columns'] if d['searchable']] + search_cols
for row in rows:
for k, v in row.items():
if k in sort_keys:
value = sort_keys[k].get(v, v)
else:
value = v
value = str(value).lower()
if k in searchable_columns and search_value in value:
results.append(row)
break
else:
results = rows
filtered_count = len(results)
# Sort results
results = sorted(results, key=lambda k: k[default_sort].lower())
sort_order = json_data['order']
for order in reversed(sort_order):
sort_key = json_data['columns'][int(order['column'])]['data']
reverse = True if order['dir'] == 'desc' else False
results = sorted(results, key=lambda k: sort_helper(k, sort_key, sort_keys), reverse=reverse)
# Paginate results
results = results[json_data['start']:(json_data['start'] + json_data['length'])]
data = {
'results': results,
'total_count': total_count,
'filtered_count': filtered_count
}
return data
def sort_helper(k, sort_key, sort_keys):
v = k[sort_key]
if sort_key in sort_keys:
v = sort_keys[sort_key].get(k[sort_key], v)
if isinstance(v, str):
v = v.lower()
return v
def sanitize_out(*dargs, **dkwargs): def sanitize_out(*dargs, **dkwargs):
""" Helper decorator that sanitized the output """ Helper decorator that sanitized the output
""" """
@ -897,7 +977,7 @@ def build_datatables_json(kwargs, dt_columns, default_sort_col=None):
return json.dumps(json_data) return json.dumps(json_data)
def humanFileSize(bytes, si=True): def human_file_size(bytes, si=True):
if str(bytes).isdigit(): if str(bytes).isdigit():
bytes = cast_to_float(bytes) bytes = cast_to_float(bytes)
else: else:
@ -919,7 +999,7 @@ def humanFileSize(bytes, si=True):
bytes /= thresh bytes /= thresh
u += 1 u += 1
return "{0:.1f} {1}".format(bytes, units[u]) return "{0:.2f} {1}".format(bytes, units[u])
def parse_condition_logic_string(s, num_cond=0): def parse_condition_logic_string(s, num_cond=0):
@ -1153,6 +1233,205 @@ def bool_true(value, return_none=False):
return False return False
def get_attrs_to_dict(obj, attrs):
d = {}
for attr, sub in attrs.items():
no_attr = False
if isinstance(obj, dict):
value = obj.get(attr, None)
else:
try:
value = getattr(obj, attr)
except AttributeError:
no_attr = True
value = None
if callable(value):
value = value()
if isinstance(sub, str):
if isinstance(value, list):
value = [getattr(o, sub, None) for o in value]
else:
value = getattr(value, sub, None)
elif isinstance(sub, dict):
if isinstance(value, list):
value = [get_attrs_to_dict(o, sub) for o in value]
else:
value = get_attrs_to_dict(value, sub)
elif callable(sub):
if isinstance(value, list):
value = [sub(o) for o in value]
else:
if no_attr:
value = sub(obj)
else:
value = sub(value)
d[attr] = value
return d
def flatten_dict(obj):
return flatten_tree(flatten_keys(obj))
def flatten_keys(obj, key='', sep='.'):
if isinstance(obj, list):
new_obj = [flatten_keys(o, key=key) for o in obj]
elif isinstance(obj, dict):
new_key = key + sep if key else ''
new_obj = {new_key + k: flatten_keys(v, key=new_key + k) for k, v in obj.items()}
else:
new_obj = obj
return new_obj
def flatten_tree(obj, key=''):
if isinstance(obj, list):
new_rows = []
for o in obj:
if isinstance(o, dict):
new_rows.extend(flatten_tree(o))
else:
new_rows.append({key: o})
elif isinstance(obj, dict):
common_keys = {}
all_rows = [[common_keys]]
for k, v in obj.items():
if isinstance(v, list):
all_rows.append(flatten_tree(v, k))
elif isinstance(v, dict):
common_keys.update(*flatten_tree(v))
else:
common_keys[k] = v
new_rows = [{k: v for r in row for k, v in r.items()}
for row in zip_longest(*all_rows, fillvalue={})]
else:
new_rows = []
return new_rows
# https://stackoverflow.com/a/14692747
def get_by_path(root, items):
"""Access a nested object in root by item sequence."""
return reduce(operator.getitem, items, root)
def set_by_path(root, items, value):
"""Set a value in a nested object in root by item sequence."""
get_by_path(root, items[:-1])[items[-1]] = value
def get_dict_value_by_path(root, attr):
split_attr = attr.split('.')
value = get_by_path(root, split_attr)
for _attr in reversed(split_attr):
value = {_attr: value}
return value
# https://stackoverflow.com/a/7205107
def dict_merge(a, b, path=None):
if path is None:
path = []
for key in b:
if key in a:
if isinstance(a[key], dict) and isinstance(b[key], dict):
dict_merge(a[key], b[key], path + [str(key)])
elif a[key] == b[key]:
pass
else:
pass
else:
a[key] = b[key]
return a
#https://stackoverflow.com/a/26853961
def dict_update(*dict_args):
"""
Given any number of dictionaries, shallow copy and merge into a new dict,
precedence goes to key value pairs in latter dictionaries.
"""
result = {}
for dictionary in dict_args:
result.update(dictionary)
return result
# https://stackoverflow.com/a/28703510
def escape_xml(value):
if value is None:
return ''
value = str(value) \
.replace("&", "&amp;") \
.replace("<", "&lt;") \
.replace(">", "&gt;") \
.replace('"', "&quot;") \
.replace("'", "&apos;")
return value
# https://gist.github.com/reimund/5435343/
def dict_to_xml(d, root_node=None, indent=4, level=0):
wrap = not bool(root_node is None or isinstance(d, list))
root = root_node or 'objects'
root_singular = root[:-1] if root.endswith('s') and isinstance(d, list) else root
xml = ''
children = []
if isinstance(d, dict):
for key, value in sorted(d.items()):
if isinstance(value, dict):
children.append(dict_to_xml(value, key, level=level + 1))
elif isinstance(value, list):
children.append(dict_to_xml(value, key, level=level + 1))
else:
xml = '{} {}="{}"'.format(xml, key, escape_xml(value))
elif isinstance(d, list):
for value in d:
# Custom tag replacement for collections/playlists
if isinstance(value, dict) and root in ('children', 'items'):
root_singular = value.get('type', root_singular)
children.append(dict_to_xml(value, root_singular, level=level))
else:
children.append(escape_xml(d))
end_tag = '>' if len(children) > 0 else '/>'
end_tag += '\n' if isinstance(d, list) or isinstance(d, dict) else ''
spaces = ' ' * level * indent
if wrap or isinstance(d, dict):
xml = '{}<{}{}{}'.format(spaces, root, xml, end_tag)
if len(children) > 0:
for child in children:
xml = '{}{}'.format(xml, child)
if wrap or isinstance(d, dict):
spaces = spaces if isinstance(d, dict) else ''
xml = '{}{}</{}>\n'.format(xml, spaces, root)
return xml
def is_hdr(bit_depth, color_space):
bit_depth = cast_to_int(bit_depth)
return bit_depth > 8 and color_space == 'bt2020nc'
def page(endpoint, *args, **kwargs): def page(endpoint, *args, **kwargs):
endpoints = { endpoints = {
'pms_image_proxy': pms_image_proxy, 'pms_image_proxy': pms_image_proxy,

View file

@ -33,6 +33,7 @@ if plexpy.PYTHON2:
import plextv import plextv
import pmsconnect import pmsconnect
import session import session
from plex import Plex
else: else:
from plexpy import common from plexpy import common
from plexpy import database from plexpy import database
@ -42,6 +43,7 @@ else:
from plexpy import plextv from plexpy import plextv
from plexpy import pmsconnect from plexpy import pmsconnect
from plexpy import session from plexpy import session
from plexpy.plex import Plex
def refresh_libraries(): def refresh_libraries():
@ -142,6 +144,163 @@ def has_library_type(section_type):
return bool(result) return bool(result)
def get_collections(section_id=None):
plex = Plex(plexpy.CONFIG.PMS_URL, session.get_session_user_token())
library = plex.get_library(section_id)
if library.type not in ('movie', 'show', 'artist'):
return []
collections = library.collection()
collections_list = []
for collection in collections:
collection_mode = collection.collectionMode
if collection_mode is None:
collection_mode = -1
collection_sort = collection.collectionSort
if collection_sort is None:
collection_sort = 0
collection_dict = {
'addedAt': helpers.datetime_to_iso(collection.addedAt),
'art': collection.art,
'childCount': collection.childCount,
'collectionMode': helpers.cast_to_int(collection_mode),
'collectionSort': helpers.cast_to_int(collection_sort),
'contentRating': collection.contentRating,
'guid': collection.guid,
'librarySectionID': collection.librarySectionID,
'librarySectionTitle': collection.librarySectionTitle,
'maxYear': collection.maxYear,
'minYear': collection.minYear,
'ratingKey': collection.ratingKey,
'subtype': collection.subtype,
'summary': collection.summary,
'thumb': collection.thumb,
'title': collection.title,
'titleSort': collection.titleSort,
'type': collection.type,
'updatedAt': helpers.datetime_to_iso(collection.updatedAt)
}
collections_list.append(collection_dict)
return collections_list
def get_collections_list(section_id=None, **kwargs):
if not section_id:
default_return = {'recordsFiltered': 0,
'recordsTotal': 0,
'draw': 0,
'data': 'null',
'error': 'Unable to get collections: missing section_id.'}
return default_return
collections = get_collections(section_id=section_id)
# Get datatables JSON data
json_data = helpers.process_json_kwargs(json_kwargs=kwargs['json_data'])
search_cols = ['title']
sort_keys = {
'collectionMode': {
-1: 'Library Default',
0: 'Hide collection',
1: 'Hide items in this collection',
2: 'Show this collection and its items'
},
'collectionSort': {
0: 'Release date',
1: 'Alphabetical'
}
}
results = helpers.process_datatable_rows(
collections, json_data, default_sort='titleSort',
search_cols=search_cols, sort_keys=sort_keys)
data = {
'recordsFiltered': results['filtered_count'],
'recordsTotal': results['total_count'],
'data': results['results'],
'draw': int(json_data['draw'])
}
return data
def get_playlists(section_id=None, user_id=None):
if user_id and not session.get_session_user_id():
import users
user_tokens = users.Users().get_tokens(user_id=user_id)
plex_token = user_tokens['server_token']
else:
plex_token = session.get_session_user_token()
if not plex_token:
return []
plex = Plex(plexpy.CONFIG.PMS_URL, plex_token)
if user_id:
playlists = plex.plex.playlists()
else:
library = plex.get_library(section_id)
playlists = library.playlist()
playlists_list = []
for playlist in playlists:
playlist_dict = {
'addedAt': helpers.datetime_to_iso(playlist.addedAt),
'composite': playlist.composite,
'duration': playlist.duration,
'guid': playlist.guid,
'leafCount': playlist.leafCount,
'librarySectionID': section_id,
'playlistType': playlist.playlistType,
'ratingKey': playlist.ratingKey,
'smart': playlist.smart,
'summary': playlist.summary,
'title': playlist.title,
'type': playlist.type,
'updatedAt': helpers.datetime_to_iso(playlist.updatedAt),
'userID': user_id
}
playlists_list.append(playlist_dict)
return playlists_list
def get_playlists_list(section_id=None, user_id=None, **kwargs):
if not section_id and not user_id:
default_return = {'recordsFiltered': 0,
'recordsTotal': 0,
'draw': 0,
'data': 'null',
'error': 'Unable to get playlists: missing section_id.'}
return default_return
playlists = get_playlists(section_id=section_id, user_id=user_id)
# Get datatables JSON data
json_data = helpers.process_json_kwargs(json_kwargs=kwargs['json_data'])
results = helpers.process_datatable_rows(
playlists, json_data, default_sort='title')
data = {
'recordsFiltered': results['filtered_count'],
'recordsTotal': results['total_count'],
'data': results['results'],
'draw': int(json_data['draw'])
}
return data
class Libraries(object): class Libraries(object):
def __init__(self): def __init__(self):

View file

@ -1072,7 +1072,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'subtitle_language_code': notify_params['subtitle_language_code'], 'subtitle_language_code': notify_params['subtitle_language_code'],
'file': notify_params['file'], 'file': notify_params['file'],
'filename': os.path.basename(notify_params['file']), 'filename': os.path.basename(notify_params['file']),
'file_size': helpers.humanFileSize(notify_params['file_size']), 'file_size': helpers.human_file_size(notify_params['file_size']),
'indexes': notify_params['indexes'], 'indexes': notify_params['indexes'],
'section_id': notify_params['section_id'], 'section_id': notify_params['section_id'],
'rating_key': notify_params['rating_key'], 'rating_key': notify_params['rating_key'],

View file

@ -3028,7 +3028,6 @@ class SCRIPTS(Notifier):
if user_id: if user_id:
user_tokens = users.Users().get_tokens(user_id=user_id) user_tokens = users.Users().get_tokens(user_id=user_id)
if user_tokens and user_tokens['server_token']:
custom_env['PLEX_USER_TOKEN'] = str(user_tokens['server_token']) custom_env['PLEX_USER_TOKEN'] = str(user_tokens['server_token'])
if self.pythonpath and plexpy.INSTALL_TYPE not in ('windows', 'macos'): if self.pythonpath and plexpy.INSTALL_TYPE not in ('windows', 'macos'):

42
plexpy/plex.py Normal file
View file

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
# This file is part of Tautulli.
#
# Tautulli is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Tautulli is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
from future.builtins import object
from future.builtins import str
from plexapi.server import PlexServer
import plexpy
if plexpy.PYTHON2:
import logger
else:
from plexpy import logger
class Plex(object):
def __init__(self, url, token):
self.plex = PlexServer(url, token)
def get_library(self, section_id):
return self.plex.library.sectionByID(str(section_id))
def get_library_items(self, section_id):
return self.get_library(str(section_id)).all()
def get_item(self, rating_key):
return self.plex.fetchItem(rating_key)

View file

@ -24,6 +24,7 @@ import json
import os import os
import time import time
from future.moves.urllib.parse import quote, quote_plus, urlencode from future.moves.urllib.parse import quote, quote_plus, urlencode
from xml.dom.minidom import Node
import plexpy import plexpy
if plexpy.PYTHON2: if plexpy.PYTHON2:
@ -31,6 +32,7 @@ if plexpy.PYTHON2:
import common import common
import helpers import helpers
import http_handler import http_handler
import libraries
import logger import logger
import plextv import plextv
import session import session
@ -40,6 +42,7 @@ else:
from plexpy import common from plexpy import common
from plexpy import helpers from plexpy import helpers
from plexpy import http_handler from plexpy import http_handler
from plexpy import libraries
from plexpy import logger from plexpy import logger
from plexpy import plextv from plexpy import plextv
from plexpy import session from plexpy import session
@ -173,6 +176,22 @@ class PmsConnect(object):
return request return request
def get_playlist_items(self, rating_key='', output_format=''):
"""
Return metadata for items of the requested playlist.
Parameters required: rating_key { Plex ratingKey }
Optional parameters: output_format { dict, json }
Output: array
"""
uri = '/playlists/' + rating_key + '/items'
request = self.request_handler.make_request(uri=uri,
request_type='GET',
output_format=output_format)
return request
def get_recently_added(self, start='0', count='0', output_format=''): def get_recently_added(self, start='0', count='0', output_format=''):
""" """
Return list of recently added items. Return list of recently added items.
@ -594,7 +613,7 @@ class PmsConnect(object):
return output return output
def get_metadata_details(self, rating_key='', sync_id='', plex_guid='', def get_metadata_details(self, rating_key='', sync_id='', plex_guid='', section_id='',
skip_cache=False, cache_key=None, return_cache=False, media_info=True): skip_cache=False, cache_key=None, return_cache=False, media_info=True):
""" """
Return processed and validated metadata list for requested item. Return processed and validated metadata list for requested item.
@ -654,6 +673,8 @@ class PmsConnect(object):
metadata_main_list = a.getElementsByTagName('Track') metadata_main_list = a.getElementsByTagName('Track')
elif a.getElementsByTagName('Photo'): elif a.getElementsByTagName('Photo'):
metadata_main_list = a.getElementsByTagName('Photo') metadata_main_list = a.getElementsByTagName('Photo')
elif a.getElementsByTagName('Playlist'):
metadata_main_list = a.getElementsByTagName('Playlist')
else: else:
logger.debug("Tautulli Pmsconnect :: Metadata failed") logger.debug("Tautulli Pmsconnect :: Metadata failed")
return {} return {}
@ -669,9 +690,13 @@ class PmsConnect(object):
if metadata_main.nodeName == 'Directory' and metadata_type == 'photo': if metadata_main.nodeName == 'Directory' and metadata_type == 'photo':
metadata_type = 'photo_album' metadata_type = 'photo_album'
section_id = helpers.get_xml_attr(a, 'librarySectionID') section_id = helpers.get_xml_attr(a, 'librarySectionID') or section_id
library_name = helpers.get_xml_attr(a, 'librarySectionTitle') library_name = helpers.get_xml_attr(a, 'librarySectionTitle')
if not library_name and section_id:
library_data = libraries.Libraries().get_details(section_id)
library_name = library_data['section_name']
directors = [] directors = []
writers = [] writers = []
actors = [] actors = []
@ -1247,7 +1272,27 @@ class PmsConnect(object):
'collections': collections, 'collections': collections,
'guids': guids, 'guids': guids,
'full_title': helpers.get_xml_attr(metadata_main, 'title'), 'full_title': helpers.get_xml_attr(metadata_main, 'title'),
'children_count': helpers.cast_to_int(helpers.get_xml_attr(metadata_main, 'childCount')),
'live': int(helpers.get_xml_attr(metadata_main, 'live') == '1')
}
elif metadata_type == 'playlist':
metadata = {'media_type': metadata_type,
'section_id': section_id,
'library_name': library_name,
'rating_key': helpers.get_xml_attr(metadata_main, 'ratingKey'),
'guid': helpers.get_xml_attr(metadata_main, 'guid'),
'title': helpers.get_xml_attr(metadata_main, 'title'),
'summary': helpers.get_xml_attr(metadata_main, 'summary'),
'duration': helpers.get_xml_attr(metadata_main, 'duration'),
'composite': helpers.get_xml_attr(metadata_main, 'composite'),
'thumb': helpers.get_xml_attr(metadata_main, 'composite'),
'added_at': helpers.get_xml_attr(metadata_main, 'addedAt'),
'updated_at': helpers.get_xml_attr(metadata_main, 'updatedAt'),
'last_viewed_at': helpers.get_xml_attr(metadata_main, 'lastViewedAt'),
'children_count': helpers.cast_to_int(helpers.get_xml_attr(metadata_main, 'leafCount')), 'children_count': helpers.cast_to_int(helpers.get_xml_attr(metadata_main, 'leafCount')),
'smart': helpers.cast_to_int(helpers.get_xml_attr(metadata_main, 'smart')),
'playlist_type': helpers.get_xml_attr(metadata_main, 'playlistType'),
'live': int(helpers.get_xml_attr(metadata_main, 'live') == '1') 'live': int(helpers.get_xml_attr(metadata_main, 'live') == '1')
} }
@ -2242,13 +2287,15 @@ class PmsConnect(object):
logger.warn("Tautulli Pmsconnect :: Failed to terminate session: %s." % msg) logger.warn("Tautulli Pmsconnect :: Failed to terminate session: %s." % msg)
return msg return msg
def get_item_children(self, rating_key='', get_grandchildren=False): def get_item_children(self, rating_key='', media_type=None, get_grandchildren=False):
""" """
Return processed and validated children list. Return processed and validated children list.
Output: array Output: array
""" """
if get_grandchildren: if media_type == 'playlist':
children_data = self.get_playlist_items(rating_key, output_format='xml')
elif get_grandchildren:
children_data = self.get_metadata_grandchildren(rating_key, output_format='xml') children_data = self.get_metadata_grandchildren(rating_key, output_format='xml')
else: else:
children_data = self.get_metadata_children(rating_key, output_format='xml') children_data = self.get_metadata_children(rating_key, output_format='xml')
@ -2272,12 +2319,9 @@ class PmsConnect(object):
result_data = [] result_data = []
if a.getElementsByTagName('Directory'): for x in a.childNodes:
result_data = a.getElementsByTagName('Directory') if x.nodeType == Node.ELEMENT_NODE and x.tagName in ('Directory', 'Video', 'Track', 'Photo'):
if a.getElementsByTagName('Video'): result_data.append(x)
result_data = a.getElementsByTagName('Video')
if a.getElementsByTagName('Track'):
result_data = a.getElementsByTagName('Track')
if result_data: if result_data:
for m in result_data: for m in result_data:
@ -2307,7 +2351,11 @@ class PmsConnect(object):
for label in m.getElementsByTagName('Label'): for label in m.getElementsByTagName('Label'):
labels.append(helpers.get_xml_attr(label, 'tag')) labels.append(helpers.get_xml_attr(label, 'tag'))
children_output = {'media_type': helpers.get_xml_attr(m, 'type'), media_type = helpers.get_xml_attr(m, 'type')
if m.nodeName == 'Directory' and media_type == 'photo':
media_type = 'photo_album'
children_output = {'media_type': media_type,
'section_id': helpers.get_xml_attr(m, 'librarySectionID'), 'section_id': helpers.get_xml_attr(m, 'librarySectionID'),
'library_name': helpers.get_xml_attr(m, 'librarySectionTitle'), 'library_name': helpers.get_xml_attr(m, 'librarySectionTitle'),
'rating_key': helpers.get_xml_attr(m, 'ratingKey'), 'rating_key': helpers.get_xml_attr(m, 'ratingKey'),

View file

@ -57,6 +57,22 @@ def get_session_user_id():
_session = get_session_info() _session = get_session_info()
return str(_session['user_id']) if _session['user_group'] == 'guest' and _session['user_id'] else None return str(_session['user_id']) if _session['user_group'] == 'guest' and _session['user_id'] else None
def get_session_user_token():
"""
Returns the user's server_token for the current logged in session
"""
_session = get_session_info()
if _session['user_group'] == 'guest' and _session['user_id']:
session_user_tokens = users.Users().get_tokens(_session['user_id'])
user_token = session_user_tokens['server_token']
else:
user_token = plexpy.CONFIG.PMS_TOKEN
return user_token
def get_session_shared_libraries(): def get_session_shared_libraries():
""" """
Returns a tuple of section_id for the current logged in session Returns a tuple of section_id for the current logged in session

View file

@ -789,6 +789,12 @@ class Users(object):
return session.friendly_name_to_username(result) return session.friendly_name_to_username(result)
def get_tokens(self, user_id=None): def get_tokens(self, user_id=None):
tokens = {
'allow_guest': 0,
'user_token': '',
'server_token': ''
}
if user_id: if user_id:
try: try:
monitor_db = database.MonitorDatabase() monitor_db = database.MonitorDatabase()
@ -802,11 +808,11 @@ class Users(object):
} }
return tokens return tokens
else: else:
return None return tokens
except: except:
return None return tokens
return None return tokens
def get_filters(self, user_id=None): def get_filters(self, user_id=None):
if not user_id: if not user_id:

View file

@ -19,8 +19,9 @@ from __future__ import unicode_literals
from future.builtins import next from future.builtins import next
from future.builtins import object from future.builtins import object
from future.builtins import str from future.builtins import str
from backports import csv
from io import open from io import open, BytesIO
import base64 import base64
import json import json
import linecache import linecache
@ -28,10 +29,11 @@ import os
import shutil import shutil
import sys import sys
import threading import threading
import zipfile
from future.moves.urllib.parse import urlencode from future.moves.urllib.parse import urlencode
import cherrypy import cherrypy
from cherrypy.lib.static import serve_file, serve_download from cherrypy.lib.static import serve_file, serve_fileobj, serve_download
from cherrypy._cperror import NotFound from cherrypy._cperror import NotFound
from hashing_passwords import make_hash from hashing_passwords import make_hash
@ -48,6 +50,7 @@ if plexpy.PYTHON2:
import config import config
import database import database
import datafactory import datafactory
import exporter
import graphs import graphs
import helpers import helpers
import http_handler import http_handler
@ -81,6 +84,7 @@ else:
from plexpy import config from plexpy import config
from plexpy import database from plexpy import database
from plexpy import datafactory from plexpy import datafactory
from plexpy import exporter
from plexpy import graphs from plexpy import graphs
from plexpy import helpers from plexpy import helpers
from plexpy import http_handler from plexpy import http_handler
@ -839,6 +843,82 @@ class WebInterface(object):
return result return result
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth()
@addtoapi("get_collections_table")
def get_collections_list(self, section_id=None, **kwargs):
""" Get the data on the Tautulli collections tables.
```
Required parameters:
section_id (str): The id of the Plex library section
Optional parameters:
None
Returns:
json:
{"draw": 1,
"recordsTotal": 5,
"data":
[...]
}
```
"""
# Check if datatables json_data was received.
# If not, then build the minimal amount of json data for a query
if not kwargs.get('json_data'):
# TODO: Find some one way to automatically get the columns
dt_columns = [("titleSort", True, True),
("collectionMode", True, True),
("collectionSort", True, True),
("childCount", True, False)]
kwargs['json_data'] = build_datatables_json(kwargs, dt_columns, "titleSort")
result = libraries.get_collections_list(section_id=section_id, **kwargs)
return result
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth()
@addtoapi("get_playlists_table")
def get_playlists_list(self, section_id=None, user_id=None, **kwargs):
""" Get the data on the Tautulli playlists tables.
```
Required parameters:
section_id (str): The section id of the Plex library, OR
user_id (str): The user id of the Plex user
Optional parameters:
None
Returns:
json:
{"draw": 1,
"recordsTotal": 5,
"data":
[...]
}
```
"""
# Check if datatables json_data was received.
# If not, then build the minimal amount of json data for a query
if not kwargs.get('json_data'):
# TODO: Find some one way to automatically get the columns
dt_columns = [("title", True, True),
("leafCount", True, True),
("duration", True, True)]
kwargs['json_data'] = build_datatables_json(kwargs, dt_columns, "title")
result = libraries.get_playlists_list(section_id=section_id,
user_id=user_id,
**kwargs)
return result
@cherrypy.expose @cherrypy.expose
@cherrypy.tools.json_out() @cherrypy.tools.json_out()
@requireAuth(member_of("admin")) @requireAuth(member_of("admin"))
@ -2997,6 +3077,7 @@ class WebInterface(object):
"backup_dir": plexpy.CONFIG.BACKUP_DIR, "backup_dir": plexpy.CONFIG.BACKUP_DIR,
"backup_interval": plexpy.CONFIG.BACKUP_INTERVAL, "backup_interval": plexpy.CONFIG.BACKUP_INTERVAL,
"cache_dir": plexpy.CONFIG.CACHE_DIR, "cache_dir": plexpy.CONFIG.CACHE_DIR,
"export_dir": plexpy.CONFIG.EXPORT_DIR,
"log_dir": plexpy.CONFIG.LOG_DIR, "log_dir": plexpy.CONFIG.LOG_DIR,
"log_blacklist": checked(plexpy.CONFIG.LOG_BLACKLIST), "log_blacklist": checked(plexpy.CONFIG.LOG_BLACKLIST),
"check_github": checked(plexpy.CONFIG.CHECK_GITHUB), "check_github": checked(plexpy.CONFIG.CHECK_GITHUB),
@ -4301,7 +4382,7 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
@requireAuth() @requireAuth()
def info(self, rating_key=None, guid=None, source=None, **kwargs): def info(self, rating_key=None, guid=None, source=None, section_id=None, user_id=None, **kwargs):
if rating_key and not str(rating_key).isdigit(): if rating_key and not str(rating_key).isdigit():
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
@ -4312,10 +4393,16 @@ class WebInterface(object):
"pms_web_url": plexpy.CONFIG.PMS_WEB_URL "pms_web_url": plexpy.CONFIG.PMS_WEB_URL
} }
if user_id:
user_data = users.Users()
user_info = user_data.get_details(user_id=user_id)
else:
user_info = {}
# Try to get metadata from the Plex server first # Try to get metadata from the Plex server first
if rating_key: if rating_key:
pms_connect = pmsconnect.PmsConnect() pms_connect = pmsconnect.PmsConnect()
metadata = pms_connect.get_metadata_details(rating_key=rating_key) metadata = pms_connect.get_metadata_details(rating_key=rating_key, section_id=section_id)
# If the item is not found on the Plex server, get the metadata from history # If the item is not found on the Plex server, get the metadata from history
if not metadata and source == 'history': if not metadata and source == 'history':
@ -4334,7 +4421,7 @@ class WebInterface(object):
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
return serve_template(templatename="info.html", metadata=metadata, title="Info", return serve_template(templatename="info.html", metadata=metadata, title="Info",
config=config, source=source) config=config, source=source, user_info=user_info)
else: else:
if get_session_user_id(): if get_session_user_id():
raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT)
@ -4343,13 +4430,14 @@ class WebInterface(object):
@cherrypy.expose @cherrypy.expose
@requireAuth() @requireAuth()
def get_item_children(self, rating_key='', **kwargs): def get_item_children(self, rating_key='', media_type=None, **kwargs):
pms_connect = pmsconnect.PmsConnect() pms_connect = pmsconnect.PmsConnect()
result = pms_connect.get_item_children(rating_key=rating_key) result = pms_connect.get_item_children(rating_key=rating_key, media_type=media_type)
if result: if result:
return serve_template(templatename="info_children_list.html", data=result, title="Children List") return serve_template(templatename="info_children_list.html", data=result,
media_type=media_type, title="Children List")
else: else:
logger.warn("Unable to retrieve data for get_item_children.") logger.warn("Unable to retrieve data for get_item_children.")
return serve_template(templatename="info_children_list.html", data=None, title="Children List") return serve_template(templatename="info_children_list.html", data=None, title="Children List")
@ -4467,8 +4555,9 @@ class WebInterface(object):
img = '/library/metadata/{}/thumb'.format(rating_key) img = '/library/metadata/{}/thumb'.format(rating_key)
if img.startswith('/library/metadata'): if img.startswith('/library/metadata'):
parts = 6 if 'composite' in img else 5
img_split = img.split('/') img_split = img.split('/')
img = '/'.join(img_split[:5]) img = '/'.join(img_split[:parts])
img_rating_key = img_split[3] img_rating_key = img_split[3]
if rating_key != img_rating_key: if rating_key != img_rating_key:
rating_key = img_rating_key rating_key = img_rating_key
@ -6426,3 +6515,307 @@ class WebInterface(object):
status['message'] = 'Database not ok' status['message'] = 'Database not ok'
return status return status
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth(member_of("admin"))
@addtoapi("get_exports_table")
def get_export_list(self, section_id=None, user_id=None, rating_key=None, **kwargs):
""" Get the data on the Tautulli export tables.
```
Required parameters:
section_id (str): The id of the Plex library section, OR
user_id (str): The id of the Plex user, OR
rating_key (str): The rating key of the exported item
Optional parameters:
order_column (str): "added_at", "sort_title", "container", "bitrate", "video_codec",
"video_resolution", "video_framerate", "audio_codec", "audio_channels",
"file_size", "last_played", "play_count"
order_dir (str): "desc" or "asc"
start (int): Row to start from, 0
length (int): Number of items to return, 25
search (str): A string to search for, "Thrones"
Returns:
json:
{"draw": 1,
"recordsTotal": 10,
"recordsFiltered": 3,
"data":
[{"row_id": 2,
"timestamp": 1596484600,
"section_id": 1,
"rating_key": 270716,
"media_type": "movie",
"media_type_title": "Movie",
"filename": "Movie - Frozen II [270716].20200803125640.json",
"complete": 1
},
{...},
{...}
]
}
```
"""
# Check if datatables json_data was received.
# If not, then build the minimal amount of json data for a query
if not kwargs.get('json_data'):
# TODO: Find some one way to automatically get the columns
dt_columns = [("timestamp", True, False),
("media_type_title", True, True),
("rating_key", True, True),
("file_format", True, True),
("filename", True, True),
("complete", True, False)]
kwargs['json_data'] = build_datatables_json(kwargs, dt_columns, "timestamp")
result = exporter.get_export_datatable(section_id=section_id,
user_id=user_id,
rating_key=rating_key,
kwargs=kwargs)
return result
@cherrypy.expose
@requireAuth(member_of("admin"))
def export_metadata_modal(self, section_id=None, user_id=None, rating_key=None,
media_type=None, sub_media_type=None,
export_type=None, **kwargs):
file_formats = exporter.Export.FILE_FORMATS
return serve_template(templatename="export_modal.html", title="Export Metadata",
section_id=section_id, user_id=user_id, rating_key=rating_key,
media_type=media_type, sub_media_type=sub_media_type,
export_type=export_type, file_formats=file_formats)
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth(member_of("admin"))
@addtoapi()
def get_export_fields(self, media_type=None, sub_media_type=None, **kwargs):
""" Get a list of available custom export fields.
```
Required parameters:
media_type (str): The media type of the fields to return
Optional parameters:
sub_media_type (str): The child media type for
collections (movie, show, artist, album, photoalbum),
or playlists (video, audio, photo)
Returns:
json:
{"metadata_fields":
[{"field": "addedAt", "level": 1},
...
],
"media_info_fields":
[{"field": "media.aspectRatio", "level": 1},
...
]
}
```
"""
custom_fields = exporter.get_custom_fields(media_type=media_type,
sub_media_type=sub_media_type)
return custom_fields
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth(member_of("admin"))
@addtoapi()
def export_metadata(self, section_id=None, user_id=None, rating_key=None, file_format='csv',
metadata_level=1, media_info_level=1,
include_thumb=False, include_art=False,
custom_fields='', export_type=None, **kwargs):
""" Export library or media metadata to a file
```
Required parameters:
section_id (int): The section id of the library items to export, OR
user_id (int): The user id of the playlist items to export,
rating_key (int): The rating key of the media item to export
Optional parameters:
file_format (str): csv (default), json, or xml
metadata_level (int): The level of metadata to export (default 1)
media_info_level (int): The level of media info to export (default 1)
include_thumb (bool): True to export poster/cover images
include_art (bool): True to export background artwork images
custom_fields (str): Comma separated list of custom fields to export
in addition to the export level selected
export_type (str): collection or playlist for library/user export,
otherwise default to all library items
Returns:
json:
{"result": "success",
"message": "Metadata export has started."
}
```
"""
result = exporter.Export(section_id=section_id,
user_id=user_id,
rating_key=rating_key,
file_format=file_format,
metadata_level=metadata_level,
media_info_level=media_info_level,
include_thumb=helpers.bool_true(include_thumb),
include_art=helpers.bool_true(include_art),
custom_fields=custom_fields,
export_type=export_type).export()
if result is True:
return {'result': 'success', 'message': 'Metadata export has started.'}
else:
return {'result': 'error', 'message': result}
@cherrypy.expose
@requireAuth(member_of("admin"))
def view_export(self, export_id=None, **kwargs):
""" Download an exported metadata file
```
Required parameters:
export_id (int): The row id of the exported file to view
Optional parameters:
None
Returns:
download
```
"""
result = exporter.get_export(export_id=export_id)
if result and result['complete'] == 1 and result['exists']:
filepath = exporter.get_export_filepath(result['filename'])
if result['file_format'] == 'csv':
with open(filepath, 'r', encoding='utf-8') as infile:
reader = csv.DictReader(infile)
table = '<table><tr><th>' + \
'</th><th>'.join(reader.fieldnames) + \
'</th></tr><tr>' + \
'</tr><tr>'.join(
'<td>' + '</td><td>'.join(row.values()) + '</td>' for row in reader) + \
'</tr></table>'
style = '<style>' \
'body {margin: 0;}' \
'table {border-collapse: collapse; overflow-y: auto; height: 100px;} ' \
'th {position: sticky; top: 0; background: #ddd; box-shadow: inset 1px 1px #000, 0 1px #000;}' \
'td {box-shadow: inset 1px -1px #000;}' \
'th, td {padding: 3px; white-space: nowrap;}' \
'</style>'
return '{style}<pre>{table}</pre>'.format(style=style, table=table)
elif result['file_format'] == 'json':
return serve_file(filepath, name=result['filename'], content_type='application/json;charset=UTF-8')
elif result['file_format'] == 'xml':
return serve_file(filepath, name=result['filename'], content_type='application/xml;charset=UTF-8')
elif result['file_format'] == 'm3u8':
return serve_file(filepath, name=result['filename'], content_type='text/plain;charset=UTF-8')
else:
if result and result.get('complete') == 0:
msg = 'Export is still being processed.'
elif result and result.get('complete') == -1:
msg = 'Export failed to process.'
elif result and not result.get('exists'):
msg = 'Export file does not exist.'
else:
msg = 'Invalid export_id provided.'
cherrypy.response.headers['Content-Type'] = 'application/json;charset=UTF-8'
return json.dumps({'result': 'error', 'message': msg}).encode('utf-8')
@cherrypy.expose
@requireAuth(member_of("admin"))
@addtoapi()
def download_export(self, export_id=None, **kwargs):
""" Download an exported metadata file
```
Required parameters:
export_id (int): The row id of the exported file to download
Optional parameters:
None
Returns:
download
```
"""
result = exporter.get_export(export_id=export_id)
if result and result['complete'] == 1 and result['exists']:
export_filepath = exporter.get_export_filepath(result['filename'])
if result['include_thumb'] or result['include_art']:
zip_filename = '{}.zip'.format(os.path.splitext(result['filename'])[0])
images_folder = exporter.get_export_filepath(result['filename'], images=True)
if os.path.exists(images_folder):
buffer = BytesIO()
temp_zip = zipfile.ZipFile(buffer, 'w')
temp_zip.write(export_filepath, arcname=result['filename'])
_images_folder = os.path.basename(images_folder)
for f in os.listdir(images_folder):
image_path = os.path.join(images_folder, f)
temp_zip.write(image_path, arcname=os.path.join(_images_folder, f))
temp_zip.close()
return serve_fileobj(buffer.getvalue(), content_type='application/zip',
disposition='attachment', name=zip_filename)
return serve_download(exporter.get_export_filepath(result['filename']), name=result['filename'])
else:
if result and result.get('complete') == 0:
msg = 'Export is still being processed.'
elif result and result.get('complete') == -1:
msg = 'Export failed to process.'
elif result and not result.get('exists'):
msg = 'Export file does not exist.'
else:
msg = 'Invalid export_id provided.'
cherrypy.response.headers['Content-Type'] = 'application/json;charset=UTF-8'
return json.dumps({'result': 'error', 'message': msg}).encode('utf-8')
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth(member_of("admin"))
@addtoapi()
def delete_export(self, export_id=None, delete_all=False, **kwargs):
""" Delete exports from Tautulli.
```
Required parameters:
export_id (int): The row id of the exported file to delete
Optional parameters:
delete_all (bool): 'true' to delete all exported files
Returns:
None
```
"""
if helpers.bool_true(delete_all):
result = exporter.delete_all_exports()
if result:
return {'result': 'success', 'message': 'All exports deleted successfully.'}
else:
return {'result': 'error', 'message': 'Failed to delete all exports.'}
else:
result = exporter.delete_export(export_id=export_id)
if result:
return {'result': 'success', 'message': 'Export deleted successfully.'}
else:
return {'result': 'error', 'message': 'Failed to delete export.'}