mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-12 08:16:06 -07:00
Merge branch 'v2.5-export' into nightly
This commit is contained in:
commit
739c977cd7
45 changed files with 6500 additions and 521 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -17,6 +17,7 @@ version.lock
|
||||||
logs/*
|
logs/*
|
||||||
backups/*
|
backups/*
|
||||||
cache/*
|
cache/*
|
||||||
|
exports/*
|
||||||
newsletters/*
|
newsletters/*
|
||||||
*.mmdb
|
*.mmdb
|
||||||
version.txt
|
version.txt
|
||||||
|
|
162
API.md
162
API.md
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
246
data/interfaces/default/export_modal.html
Normal file
246
data/interfaces/default/export_modal.html
Normal 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">×</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>
|
|
@ -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>
|
||||||
|
|
|
@ -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']} · E${data['media_index']}</h3>
|
<h3 class="hidden-xs">S${data['parent_media_index']} · 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> </h1><h1>${data['title']}</h1>
|
<h1> </h1><h1>${data['title']}</h1>
|
||||||
% elif data['media_type'] == 'season':
|
% elif data['media_type'] == 'season':
|
||||||
<h1> </h1><h1><a href="${page('info', data['parent_rating_key'])}">${data['parent_title']}</a></h1>
|
<h1> </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> Loading track list...</div>
|
<div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i> 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> 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,100 +509,186 @@ 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> Loading movies list...</div>
|
<div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i> 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>
|
||||||
% endif
|
% elif data['media_type'] == 'playlist':
|
||||||
% if data['media_type'] != 'collection':
|
|
||||||
<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">
|
||||||
% if data['media_type'] in ('artist', 'album', 'track'):
|
<span>${MEDIA_TYPE_HEADERS[data['playlist_type']]} List for <strong>${data['title']}</strong></span>
|
||||||
<span>Play History for <strong>${data['title']}</strong></span>
|
|
||||||
% else:
|
|
||||||
<span>Watch History for <strong>${data['title']}</strong></span>
|
|
||||||
% endif
|
|
||||||
</div>
|
|
||||||
<div class="button-bar">
|
|
||||||
% if _session['user_group'] == 'admin':
|
|
||||||
<div class="alert alert-danger alert-edit" role="alert" id="row-edit-mode-alert"><i class="fa fa-exclamation-triangle"></i> Select rows to delete. Data is deleted upon exiting delete mode.</div>
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="row-edit-mode">
|
|
||||||
<i class="fa fa-trash-o"></i> Delete mode
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
% if source == 'history':
|
|
||||||
<div class="btn-group">
|
|
||||||
<a href="update_metadata?rating_key=${data['rating_key']}&update=True" class="btn btn-danger btn-edit" id="fix-metadata">
|
|
||||||
<i class="fa fa-wrench"></i> Fix Metadata
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
% endif
|
|
||||||
% if data.get('tvmaze_id') or data.get('themoviedb_id') or data.get('musicbrainz_id'):
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-lookup-info"
|
|
||||||
data-id="${data['grandparent_rating_key'] if data['media_type'] == 'episode' else data['parent_rating_key'] if data['media_type'] == 'season' else data['rating_key']}"
|
|
||||||
data-title="${data['grandparent_title'] if data['media_type'] == 'episode' else data['parent_title'] if data['media_type'] == 'season' else data['title']}">
|
|
||||||
<i class="fa fa-search"></i> Delete Lookup Info
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
% endif
|
|
||||||
% if data.get('poster_url'):
|
|
||||||
<div class="btn-group" id="hosted-poster">
|
|
||||||
% if data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track':
|
|
||||||
<span class="hosted-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="80" data-width="80" style="display: inline-flex;">
|
|
||||||
% else:
|
|
||||||
<span class="hosted-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="120" data-width="80" style="display: inline-flex;">
|
|
||||||
% endif
|
|
||||||
<button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-hosted-poster"
|
|
||||||
data-id="${data['parent_rating_key'] if data['media_type'] in ('episode', 'track') else data['rating_key']}"
|
|
||||||
data-title="${data["poster_title"]}">
|
|
||||||
<i class="fa fa-picture-o"></i> Delete ${data['img_service']} Poster
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
% endif
|
|
||||||
% if not data['live']:
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-dark" data-toggle="modal" aria-pressed="false" autocomplete="off" id="send-recently-added-notification"
|
|
||||||
data-id="${data['rating_key']}">
|
|
||||||
<i class="fa fa-bell"></i> Recently Added Notification
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
% endif
|
|
||||||
% endif
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group colvis-button-bar"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-card-back">
|
<div class="table-card-back">
|
||||||
<table class="display history_table" id="history_table-RK-${data['rating_key']}" width="100%">
|
<div id="children-list" class="children-list"><i class="fa fa-refresh fa-spin"></i> Loading playlist items...</div>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th align="left" id="delete">Delete</th>
|
|
||||||
<th align="left" id="date">Date</th>
|
|
||||||
<th align="left" id="friendly_name">User</th>
|
|
||||||
<th align="left" id="ip_address">IP Address</th>
|
|
||||||
<th align="left" id="platform">Platform</th>
|
|
||||||
<th align="left" id="product">Product</th>
|
|
||||||
<th align="left" id="player">Player</th>
|
|
||||||
<th align="left" id="title">Title</th>
|
|
||||||
<th align="left" id="started">Started</th>
|
|
||||||
<th align="left" id="paused_counter">Paused</th>
|
|
||||||
<th align="left" id="stopped">Stopped</th>
|
|
||||||
<th align="left" id="duration">Duration</th>
|
|
||||||
<th align="left" id="percent_complete"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
% endif
|
% endif
|
||||||
|
<%
|
||||||
|
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="table-card-header">
|
||||||
|
<div class="header-bar">
|
||||||
|
% if data['media_type'] in ('artist', 'album', 'track'):
|
||||||
|
<span>Play History for <strong>${data['title']}</strong></span>
|
||||||
|
% else:
|
||||||
|
<span>Watch History for <strong>${data['title']}</strong></span>
|
||||||
|
% endif
|
||||||
|
</div>
|
||||||
|
<div class="button-bar">
|
||||||
|
% if _session['user_group'] == 'admin':
|
||||||
|
<div class="alert alert-danger alert-edit" role="alert" id="row-edit-mode-alert"><i class="fa fa-exclamation-triangle"></i> Select rows to delete. Data is deleted upon exiting delete mode.</div>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-danger btn-edit" data-toggle="button" aria-pressed="false" autocomplete="off" id="row-edit-mode">
|
||||||
|
<i class="fa fa-trash-o"></i> Delete mode
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
% if source == 'history':
|
||||||
|
<div class="btn-group">
|
||||||
|
<a href="update_metadata?rating_key=${data['rating_key']}&update=True" class="btn btn-danger btn-edit" id="fix-metadata">
|
||||||
|
<i class="fa fa-wrench"></i> Fix Metadata
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
% endif
|
||||||
|
% if data.get('tvmaze_id') or data.get('themoviedb_id') or data.get('musicbrainz_id'):
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-lookup-info"
|
||||||
|
data-id="${data['grandparent_rating_key'] if data['media_type'] == 'episode' else data['parent_rating_key'] if data['media_type'] == 'season' else data['rating_key']}"
|
||||||
|
data-title="${data['grandparent_title'] if data['media_type'] == 'episode' else data['parent_title'] if data['media_type'] == 'season' else data['title']}">
|
||||||
|
<i class="fa fa-search"></i> Delete Lookup Info
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
% endif
|
||||||
|
% if data.get('poster_url'):
|
||||||
|
<div class="btn-group" id="hosted-poster">
|
||||||
|
% if data['media_type'] == 'artist' or data['media_type'] == 'album' or data['media_type'] == 'track':
|
||||||
|
<span class="hosted-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="80" data-width="80" style="display: inline-flex;">
|
||||||
|
% else:
|
||||||
|
<span class="hosted-poster-tooltip" data-toggle="popover" data-img="${data['poster_url']}" data-height="120" data-width="80" style="display: inline-flex;">
|
||||||
|
% endif
|
||||||
|
<button class="btn btn-danger btn-edit" data-toggle="modal" aria-pressed="false" autocomplete="off" id="delete-hosted-poster"
|
||||||
|
data-id="${data['parent_rating_key'] if data['media_type'] in ('episode', 'track') else data['rating_key']}"
|
||||||
|
data-title="${data["poster_title"]}">
|
||||||
|
<i class="fa fa-picture-o"></i> Delete ${data['img_service']} Poster
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
% endif
|
||||||
|
% if not data['live']:
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-dark" data-toggle="modal" aria-pressed="false" autocomplete="off" id="send-recently-added-notification"
|
||||||
|
data-id="${data['rating_key']}">
|
||||||
|
<i class="fa fa-bell"></i> Recently Added Notification
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
% endif
|
||||||
|
% endif
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group colvis-button-bar" id="button-bar-history"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-card-back">
|
||||||
|
<table class="display history_table" id="history_table-RK-${data['rating_key']}" width="100%">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th align="left" id="delete">Delete</th>
|
||||||
|
<th align="left" id="date">Date</th>
|
||||||
|
<th align="left" id="friendly_name">User</th>
|
||||||
|
<th align="left" id="ip_address">IP Address</th>
|
||||||
|
<th align="left" id="platform">Platform</th>
|
||||||
|
<th align="left" id="product">Product</th>
|
||||||
|
<th align="left" id="player">Player</th>
|
||||||
|
<th align="left" id="title">Title</th>
|
||||||
|
<th align="left" id="started">Started</th>
|
||||||
|
<th align="left" id="paused_counter">Paused</th>
|
||||||
|
<th align="left" id="stopped">Stopped</th>
|
||||||
|
<th align="left" id="duration">Duration</th>
|
||||||
|
<th align="left" id="percent_complete"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
% 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,96 +853,43 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
$('#row-edit-mode').on('click', function() {
|
$(document).ready(function () {
|
||||||
$('#row-edit-mode-alert').fadeIn(200);
|
loadHistoryTable();
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#refresh-history-list").click(function () {
|
$("#refresh-history-list").click(function () {
|
||||||
history_table.draw();
|
history_table.draw();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send recently added notification
|
|
||||||
$('#send-recently-added-notification').on('click', function () {
|
|
||||||
var rating_key = $(this).data('id');
|
|
||||||
|
|
||||||
$('#send-recently-added-modal').modal();
|
|
||||||
$('#send-recently-added-modal').one('click', '#confirm-send-notification', function () {
|
|
||||||
$.ajax({
|
|
||||||
url: 'send_manual_on_created',
|
|
||||||
data: {
|
|
||||||
rating_key: rating_key,
|
|
||||||
notifier_id: $('#send-notification-notifier option:selected').val()
|
|
||||||
},
|
|
||||||
async: true,
|
|
||||||
success: function (data) {
|
|
||||||
if (data.result === 'success') {
|
|
||||||
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>
|
</script>
|
||||||
% endif
|
% endif
|
||||||
% if data['media_type'] in ('show', 'season', 'artist', 'album', 'collection'):
|
% if data['media_type'] in ('show', 'season', 'artist', 'album', 'photo_album', 'collection', 'playlist'):
|
||||||
<script>
|
<script>
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: 'get_item_children',
|
url: 'get_item_children',
|
||||||
type: 'GET',
|
type: 'GET',
|
||||||
async: true,
|
async: true,
|
||||||
data: { rating_key : "${data['rating_key']}" },
|
data: {
|
||||||
|
rating_key: "${data['rating_key']}",
|
||||||
|
media_type: "${data['media_type']}"
|
||||||
|
},
|
||||||
complete: function(xhr, status) {
|
complete: function(xhr, status) {
|
||||||
$("#children-list").html(xhr.responseText);
|
$("#children-list").html(xhr.responseText);
|
||||||
}
|
}
|
||||||
|
@ -804,7 +903,7 @@ DOCUMENTATION :: END
|
||||||
type: 'GET',
|
type: 'GET',
|
||||||
async: true,
|
async: true,
|
||||||
data: {
|
data: {
|
||||||
rating_key : "${data['rating_key']}",
|
rating_key: "${data['rating_key']}",
|
||||||
title: "${data['title']}"
|
title: "${data['title']}"
|
||||||
},
|
},
|
||||||
complete: function(xhr, status) {
|
complete: function(xhr, status) {
|
||||||
|
@ -814,12 +913,29 @@ DOCUMENTATION :: END
|
||||||
</script>
|
</script>
|
||||||
% endif
|
% endif
|
||||||
<script>
|
<script>
|
||||||
$('.metadata-xml').on('tripleclick', function () {
|
$(document).ready(function () {
|
||||||
openPlexXML("/library/metadata/${data['rating_key']}");
|
// 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#airdate").html(moment($("#airdate").text()).format('MMM DD, YYYY'));
|
$('a[data-toggle="tab"]').on('shown.bs.tab', function (e) {
|
||||||
$("#runtime").html(millisecondsToMinutes($("#runtime").text(), true));
|
$.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 });
|
$('div.art-face').animate({ opacity: 0.2 }, { duration: 1000 });
|
||||||
$('#channel-icon').popover({
|
$('#channel-icon').popover({
|
||||||
selector: '[data-toggle=popover]',
|
selector: '[data-toggle=popover]',
|
||||||
|
@ -833,6 +949,127 @@ DOCUMENTATION :: END
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</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-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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send recently added notification
|
||||||
|
$('#send-recently-added-notification').on('click', function () {
|
||||||
|
var rating_key = $(this).data('id');
|
||||||
|
|
||||||
|
$('#send-recently-added-modal').modal();
|
||||||
|
$('#send-recently-added-modal').one('click', '#confirm-send-notification', function () {
|
||||||
|
$.ajax({
|
||||||
|
url: 'send_manual_on_created',
|
||||||
|
data: {
|
||||||
|
rating_key: rating_key,
|
||||||
|
notifier_id: $('#send-notification-notifier option:selected').val()
|
||||||
|
},
|
||||||
|
async: true,
|
||||||
|
success: function (data) {
|
||||||
|
if (data.result === 'success') {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('.metadata-xml').on('tripleclick', function () {
|
||||||
|
openPlexXML("/library/metadata/${data['rating_key']}");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
% endif
|
||||||
% if data.get('poster_url'):
|
% if data.get('poster_url'):
|
||||||
<script>
|
<script>
|
||||||
$('#hosted-poster').popover({
|
$('#hosted-poster').popover({
|
||||||
|
|
|
@ -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"> ${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">
|
||||||
% if child['original_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']:
|
||||||
<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>
|
||||||
% else:
|
% elif data['children_type'] == 'photo':
|
||||||
<div class="item-children-list-item-odd">
|
<% e = 'even' if loop.index % 2 == 0 else 'odd' %>
|
||||||
<span class="item-children-list-item-index"> ${child['media_index']}</span>
|
<div class="item-children-list-item-${e}">
|
||||||
<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-index">${loop.index + 1}</span>
|
||||||
% if child['original_title']:
|
<span class="item-children-list-item-title">
|
||||||
<span class="text-muted"> - ${child['original_title']}</span>
|
% if child['media_type'] == 'photo_album':
|
||||||
% endif
|
<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:
|
||||||
|
<span class="media-type-tooltip" data-toggle="tooltip" title="Photo"><i class="fa fa-picture-o fa-fw"></i></span>
|
||||||
|
% 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>
|
||||||
|
% 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> · <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>
|
</div>
|
||||||
% endif
|
|
||||||
% 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
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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'
|
||||||
|
}
|
|
@ -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 );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
});
|
98
data/interfaces/default/js/tables/collections_table.js
Normal file
98
data/interfaces/default/js/tables/collections_table.js
Normal 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> Fetching rows...";
|
||||||
|
showMsg(msg, false, false, 0);
|
||||||
|
},
|
||||||
|
"rowCallback": function (row, rowData, rowIndex) {
|
||||||
|
}
|
||||||
|
};
|
220
data/interfaces/default/js/tables/export_table.js
Normal file
220
data/interfaces/default/js/tables/export_table.js
Normal 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> 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;
|
|
@ -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,
|
||||||
|
|
|
@ -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 + ' ' + thumb_popover + '</div></div>');
|
$(td).html('<div class="history-title"><a href="' + page('info', rowData['rating_key']) + '"><div style="float: left;">' + media_type + ' ' + 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 + ' ' + 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 + ' ' + 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 + ' ' + 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 + ' ' + thumb_popover + '</div></a></div>');
|
||||||
} else {
|
} else {
|
||||||
$(td).html(cellData);
|
$(td).html(cellData);
|
||||||
}
|
}
|
||||||
|
|
88
data/interfaces/default/js/tables/playlists_table.js
Normal file
88
data/interfaces/default/js/tables/playlists_table.js
Normal 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> '
|
||||||
|
}
|
||||||
|
var breadcrumb = '';
|
||||||
|
if (rowData['userID']) {
|
||||||
|
breadcrumb = '&user_id=' + rowData['userID'];
|
||||||
|
} else if (rowData['librarySectionID']) {
|
||||||
|
breadcrumb = '§ion_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> Fetching rows...";
|
||||||
|
showMsg(msg, false, false, 0);
|
||||||
|
$('[data-toggle="tooltip"]').tooltip('destroy');
|
||||||
|
},
|
||||||
|
"rowCallback": function (row, rowData, rowIndex) {
|
||||||
|
}
|
||||||
|
};
|
|
@ -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,
|
||||||
|
|
|
@ -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> 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> 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');
|
type: 'POST',
|
||||||
$.ajax({
|
data: function ( d ) {
|
||||||
url: 'edit_library_dialog',
|
return {
|
||||||
data: { section_id: section_id },
|
json_data: JSON.stringify( d ),
|
||||||
cache: false,
|
section_id: section_id
|
||||||
async: true,
|
};
|
||||||
complete: function(xhr, status) {
|
|
||||||
$("#edit-library-modal").html(xhr.responseText);
|
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
});
|
playlists_table = $('#playlists_table-SID-${data["section_id"]}').DataTable(playlists_table_options);
|
||||||
|
|
||||||
$('#row-edit-mode').on('click', function() {
|
var colvis = new $.fn.dataTable.ColVis(playlists_table, { buttonText: '<i class="fa fa-columns"></i> Select columns', buttonClass: 'btn btn-dark' });
|
||||||
$('#row-edit-mode-alert').fadeIn(200);
|
$(colvis.button()).appendTo('#button-bar-playlists');
|
||||||
|
|
||||||
if ($(this).hasClass('active')) {
|
clearSearchButton('playlists_table-SID-${data["section_id"]}', playlists_table);
|
||||||
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 () {
|
$('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>
|
|
@ -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';
|
||||||
|
|
|
@ -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':
|
||||||
|
<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();
|
||||||
|
});
|
||||||
|
|
||||||
% if _session['user_group'] == 'admin':
|
|
||||||
$("#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>
|
|
@ -1 +1 @@
|
||||||
__path__ = __import__('pkgutil').extend_path(__path__, __name__)
|
__import__('pkg_resources').declare_namespace(__name__)
|
||||||
|
|
979
lib/backports/csv.py
Normal file
979
lib/backports/csv.py
Normal 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
|
|
@ -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)
|
||||||
|
|
|
@ -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§ionID=%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§ionID=%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§ionID=%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§ionID=%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
|
||||||
|
|
||||||
|
|
|
@ -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'))
|
||||||
|
|
|
@ -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`. """
|
||||||
|
|
|
@ -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 = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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(',')))
|
||||||
|
|
|
@ -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
2040
plexpy/exporter.py
Normal file
File diff suppressed because it is too large
Load diff
|
@ -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("&", "&") \
|
||||||
|
.replace("<", "<") \
|
||||||
|
.replace(">", ">") \
|
||||||
|
.replace('"', """) \
|
||||||
|
.replace("'", "'")
|
||||||
|
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,
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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'],
|
||||||
|
|
|
@ -3028,8 +3028,7 @@ 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'):
|
||||||
custom_env['PYTHONPATH'] = os.pathsep.join([p for p in sys.path if p])
|
custom_env['PYTHONPATH'] = os.pathsep.join([p for p in sys.path if p])
|
||||||
|
|
42
plexpy/plex.py
Normal file
42
plexpy/plex.py
Normal 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)
|
|
@ -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'),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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.'}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue