diff --git a/.gitignore b/.gitignore index 6bb5c9b5..e55fd0af 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ version.lock logs/* backups/* cache/* +exports/* newsletters/* *.mmdb version.txt diff --git a/API.md b/API.md index c1975144..056fb896 100644 --- a/API.md +++ b/API.md @@ -115,6 +115,21 @@ Returns: 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 rows from Tautulli. @@ -334,6 +349,21 @@ Download the Tautulli configuration 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 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 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 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 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 graph data by date. diff --git a/data/interfaces/default/base.html b/data/interfaces/default/base.html index 18ec0c23..aae730c4 100644 --- a/data/interfaces/default/base.html +++ b/data/interfaces/default/base.html @@ -15,6 +15,8 @@ + + @@ -294,6 +296,7 @@ ${next.modalIncludes()} + diff --git a/data/interfaces/default/configuration_table.html b/data/interfaces/default/configuration_table.html index 989b1766..1fed45bd 100644 --- a/data/interfaces/default/configuration_table.html +++ b/data/interfaces/default/configuration_table.html @@ -49,6 +49,10 @@ DOCUMENTATION :: END Cache Directory: ${plexpy.CONFIG.CACHE_DIR} + + Export Directory: + ${plexpy.CONFIG.EXPORT_DIR} + Newsletter Directory: ${plexpy.CONFIG.NEWSLETTER_DIR} diff --git a/data/interfaces/default/css/dataTables.colVis.css b/data/interfaces/default/css/dataTables.colVis.css index 94bcdd39..dcbfa76f 100644 --- a/data/interfaces/default/css/dataTables.colVis.css +++ b/data/interfaces/default/css/dataTables.colVis.css @@ -71,7 +71,7 @@ ul.ColVis_collection { list-style: none; width: 150px; padding: 8px 8px 4px 8px; - margin: 10px 0px 0px 0px; + margin: 10px 0px 10px 0px; background-color: #444; overflow: hidden; z-index: 2002; diff --git a/data/interfaces/default/css/tautulli.css b/data/interfaces/default/css/tautulli.css index 0cedff04..63f2ed43 100644 --- a/data/interfaces/default/css/tautulli.css +++ b/data/interfaces/default/css/tautulli.css @@ -217,6 +217,10 @@ select.form-control:focus, .selectize-dropdown .optgroup-header { font-weight: bold; } +.selectize-dropdown [data-selectable].option-disabled { + color: #aaa; + cursor: default; +} select.form-control option { color: #555; background-color: #eee; @@ -1750,6 +1754,7 @@ a:hover .dashboard-recent-media-cover { box-shadow: inset 0 0 0 2px #e9a049; opacity: 0; transition: opacity .2s; + z-index: 2; } .summary-poster-face-overlay span { display: block; @@ -1963,7 +1968,10 @@ a:hover .summary-poster-face-track .summary-poster-face-overlay span { .item-children-instance { list-style: none; margin: 0; - overflow: hidden; + overflow: auto; +} +.item-children-instance.max-height { + max-height: 875px; } .item-children-instance li { float: left; @@ -2099,7 +2107,7 @@ a:hover .item-children-poster { } .item-children-list-item-title { display: inline-block; - width: calc(100% - 110px); + /*width: calc(100% - 110px);*/ text-overflow: ellipsis; overflow: hidden; white-space: nowrap; @@ -2109,9 +2117,16 @@ a:hover .item-children-poster { color: #777; text-align: right; display: inline-block; - width: 40px; + width: 60px; margin-right: 20px; } +.nav-list { + list-style: none; + padding: 0; +} +.nav-list.nav-pills > li > a { + margin-bottom: 0; +} #new_title h3 { color: #E5A00D; font-size: 14px; @@ -2185,32 +2200,17 @@ li.advanced-setting { .user-info-username { font-size: 24px; color: #eee; - padding-top: 27px; + padding-top: 15px; padding-left: 105px; } .user-info-nav { margin-top: 15px; -} -.user-info-nav > .active > a { - color: #cc7b19; + padding-left: 105px; } .nav-tabs > .active > a:hover, .nav-tabs > .active > a:focus { 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 ul { @@ -3485,6 +3485,9 @@ pre::-webkit-scrollbar-thumb { .selectize-input input[type='text'] { height: 20px; } +.selectize-input.disabled, .selectize-input.disabled * { + cursor: not-allowed !important; +} .small-muted { font-size: small; color: #777; @@ -3707,6 +3710,20 @@ a:hover .overlay-refresh-image { a:hover .overlay-refresh-image:hover { 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 { color: #aaa; display: none; diff --git a/data/interfaces/default/export_modal.html b/data/interfaces/default/export_modal.html new file mode 100644 index 00000000..72553834 --- /dev/null +++ b/data/interfaces/default/export_modal.html @@ -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 + + + + + \ No newline at end of file diff --git a/data/interfaces/default/graphs.html b/data/interfaces/default/graphs.html index c74fa590..43e83ef3 100644 --- a/data/interfaces/default/graphs.html +++ b/data/interfaces/default/graphs.html @@ -40,7 +40,7 @@ -
+
@@ -158,10 +183,13 @@ DOCUMENTATION :: END
- % if data['media_type'] == 'movie' or data['live']: - % endif + <%def name="javascriptIncludes()"> @@ -641,8 +791,10 @@ DOCUMENTATION :: END % if metadata: <% data = defaultdict(None, **metadata) + history_user_id = '' if _session['user_group'] == 'admin' else _session['user_id'] %> + % if data['live']: % endif -% if data['media_type'] != 'collection': +% if data['media_type'] in ('movie', 'show', 'season', 'episode', 'artist', 'album', 'track'): % endif -% if data['media_type'] in ('show', 'season', 'artist', 'album', 'collection'): +% if data['media_type'] in ('show', 'season', 'artist', 'album', 'photo_album', 'collection', 'playlist'): % endif +% if _session['user_group'] == 'admin': + +% endif % if data.get('poster_url'): + <% f = 'h:mm:ss' if cast_to_int(child['duration']) >= 3600000 else 'm:ss' %> +
- % else: -
-  ${child['media_index']} - ${child['title']} - % if child['original_title']: - - ${child['original_title']} - % endif + % elif data['children_type'] == 'photo': + <% e = 'even' if loop.index % 2 == 0 else 'odd' %> +
+ ${loop.index + 1} + + % if child['media_type'] == 'photo_album': + + % elif child['media_type'] == 'clip': + + % else: + + % endif + ${child['title']} + % if child['duration']: - + <% f = 'h:mm:ss' if cast_to_int(child['duration']) >= 3600000 else 'm:ss' %> + + % endif +
+ % elif media_type == 'playlist': + <% e = 'even' if loop.index % 2 == 0 else 'odd' %> +
+ ${loop.index + 1} + + % if child['media_type'] == 'movie': + + + ${child['title']} + + (${child['year']}) + % elif child['media_type'] == 'episode': + + + ${child['grandparent_title']} + - + + ${child['title']} + + (S${child['parent_media_index']} · E${child['media_index']}) + % elif child['media_type'] == 'track': + + + ${child['title']} + - + + ${child['grandparent_title']} + + (${child['parent_title']}) + % elif child['media_type'] == 'photo': + + + ${child['title']} + + % if child['grandparent_title']: + - + ${child['grandparent_title']} + + % endif + (${child['parent_title']}) + % elif child['media_type'] == 'clip': + + + ${child['title']} + + (${child['parent_title']}) + % endif + + % if child['duration']: + + <% f = 'h:mm:ss' if cast_to_int(child['duration']) >= 3600000 else 'm:ss' %> + + + % endif
- % endif % endif % endif % endfor
+ % endif % endif diff --git a/data/interfaces/default/js/dataTables.colVis.js b/data/interfaces/default/js/dataTables.colVis.js index cc13cd8c..1a2df816 100644 --- a/data/interfaces/default/js/dataTables.colVis.js +++ b/data/interfaces/default/js/dataTables.colVis.js @@ -790,6 +790,9 @@ ColVis.prototype = { oStyle.top = oPos.top+"px"; oStyle.left = iDivX+"px"; + var iDocWidth = $(document).width(); + var iDocHeight = $(document).height(); + document.body.appendChild( nBackground ); document.body.appendChild( nHidden ); document.body.appendChild( this.dom.catcher ); @@ -819,12 +822,17 @@ ColVis.prototype = { var iDivWidth = $(nHidden).outerWidth(); 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 ) { nHidden.style.left = (iDocWidth-iDivWidth)+"px"; } + if ( iDivY + iDivHeight > iDocHeight ) + { + nHidden.style.top = (oPos.top - iDivHeight - iDivMarginTop - iDivMarginBottom)+"px"; + } } this.s.hidden = false; @@ -846,7 +854,8 @@ ColVis.prototype = { this.s.hidden = true; $(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) { diff --git a/data/interfaces/default/js/script.js b/data/interfaces/default/js/script.js index 57f0dee5..cfd047a6 100644 --- a/data/interfaces/default/js/script.js +++ b/data/interfaces/default/js/script.js @@ -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 () { return this.replace(/\w\S*/g, function (txt) { 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 $.fn.countdown = function (callback, duration, message) { // If no message is provided, we use an empty string @@ -803,3 +835,16 @@ function user_page(user_id, user) { 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' +} \ No newline at end of file diff --git a/data/interfaces/default/js/selectize.plugin.disable-options.js b/data/interfaces/default/js/selectize.plugin.disable-options.js new file mode 100644 index 00000000..0a5ed8fc --- /dev/null +++ b/data/interfaces/default/js/selectize.plugin.disable-options.js @@ -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 , Vaughn Draughon + */ + + 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 ); + } + } ); + } +}); \ No newline at end of file diff --git a/data/interfaces/default/js/tables/collections_table.js b/data/interfaces/default/js/tables/collections_table.js new file mode 100644 index 00000000..a3cde91c --- /dev/null +++ b/data/interfaces/default/js/tables/collections_table.js @@ -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": "", + "emptyTable": "No data in table", + "loadingRecords": ' Loading items...
' + }, + "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('' + rowData['title'] + ''); + } + }, + "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 = "  Fetching rows..."; + showMsg(msg, false, false, 0); + }, + "rowCallback": function (row, rowData, rowIndex) { + } +}; \ No newline at end of file diff --git a/data/interfaces/default/js/tables/export_table.js b/data/interfaces/default/js/tables/export_table.js new file mode 100644 index 00000000..5050abd7 --- /dev/null +++ b/data/interfaces/default/js/tables/export_table.js @@ -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": "", + "emptyTable": "No data in table", + "loadingRecords": ' Loading items...
' + }, + "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('' + cellData + ''); + } + }, + "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('' + cellData + ''); + } 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(''); + } else if (cellData === 0) { + $(td).html(' Processing'); + } else if (cellData === -1) { + $(td).html(' Failed'); + } else { + $(td).html(' Not Found'); + } + }, + "width": "7%", + "className": "export_download" + }, + { + "targets": [10], + "data": null, + "createdCell": function (td, cellData, rowData, row, col) { + if (rowData['complete'] !== 0) { + $(td).html(''); + } else { + $(td).html(' Delete'); + } + }, + "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 = "  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?

' + rowData['filename'] + ''; + 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; \ No newline at end of file diff --git a/data/interfaces/default/js/tables/libraries.js b/data/interfaces/default/js/tables/libraries.js index 34239c3d..a768f3e0 100644 --- a/data/interfaces/default/js/tables/libraries.js +++ b/data/interfaces/default/js/tables/libraries.js @@ -192,7 +192,7 @@ libraries_list_table_options = { "data": "duration", "createdCell": function (td, cellData, rowData, row, col) { if (cellData !== null && cellData !== '') { - $(td).html(humanTimeClean(cellData)); + $(td).html(humanDuration(cellData, 'dhm', 's')); } }, "searchable": false, diff --git a/data/interfaces/default/js/tables/media_info_table.js b/data/interfaces/default/js/tables/media_info_table.js index 0636dd82..511667dc 100644 --- a/data/interfaces/default/js/tables/media_info_table.js +++ b/data/interfaces/default/js/tables/media_info_table.js @@ -107,15 +107,15 @@ media_info_table_options = { } else if (rowData['media_type'] === 'photo_album') { media_type = ''; thumb_popover = '' + rowData['title'] + ''; - $(td).html('
' + media_type + ' ' + thumb_popover + '
'); + $(td).html('
' + media_type + ' ' + thumb_popover + '
'); } else if (rowData['media_type'] === 'photo') { media_type = ''; thumb_popover = '' + rowData['title'] + ''; - $(td).html('
' + media_type + ' ' + thumb_popover + '
'); + $(td).html('
' + media_type + ' ' + thumb_popover + '
'); } else if (rowData['media_type'] === 'clip') { media_type = ''; thumb_popover = '' + rowData['title'] + ''; - $(td).html('
' + media_type + ' ' + thumb_popover + '
'); + $(td).html('
' + media_type + ' ' + thumb_popover + '
'); } else { $(td).html(cellData); } diff --git a/data/interfaces/default/js/tables/playlists_table.js b/data/interfaces/default/js/tables/playlists_table.js new file mode 100644 index 00000000..25474040 --- /dev/null +++ b/data/interfaces/default/js/tables/playlists_table.js @@ -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": "", + "emptyTable": "No data in table", + "loadingRecords": ' Loading items...
' + }, + "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 = ''; + if (rowData['smart']) { + smart = ' ' + } + var breadcrumb = ''; + if (rowData['userID']) { + breadcrumb = '&user_id=' + rowData['userID']; + } else if (rowData['librarySectionID']) { + breadcrumb = '§ion_id=' + rowData['librarySectionID']; + } + $(td).html('' + smart + cellData + ''); + } + }, + "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 = "  Fetching rows..."; + showMsg(msg, false, false, 0); + $('[data-toggle="tooltip"]').tooltip('destroy'); + }, + "rowCallback": function (row, rowData, rowIndex) { + } +}; \ No newline at end of file diff --git a/data/interfaces/default/js/tables/users.js b/data/interfaces/default/js/tables/users.js index 8256dd21..d581020e 100644 --- a/data/interfaces/default/js/tables/users.js +++ b/data/interfaces/default/js/tables/users.js @@ -212,7 +212,7 @@ users_list_table_options = { "data": "duration", "createdCell": function (td, cellData, rowData, row, col) { if (cellData !== null && cellData !== '') { - $(td).html(humanTimeClean(cellData)); + $(td).html(humanDuration(cellData, 'dhm', 's')); } }, "searchable": false, diff --git a/data/interfaces/default/library.html b/data/interfaces/default/library.html index 99a2deaf..ff1cace5 100644 --- a/data/interfaces/default/library.html +++ b/data/interfaces/default/library.html @@ -87,12 +87,17 @@ DOCUMENTATION :: END % endif
-
+ % if _session['user_group'] == 'admin':
% if config['get_file_sizes'] and data['section_id'] in config['get_file_sizes_hold']['section_ids']:
- % else: - + % endif
- Media Info for + Media Info for ${data['section_name']} @@ -305,6 +309,155 @@ DOCUMENTATION :: END
+ % endif +
+
+
+
+
+
+ + Collections for + ${data['section_name']} + + +
+
+ % if _session['user_group'] == 'admin': +
+ +
+ % endif +
+ +
+
+
+
+
+ + + + + + + + + + +
Collection TitleCollection ModeCollection SortCollection Items
+
+
+
+
+
+
+
+
+
+
+
+ + Playlists for + ${data['section_name']} + + +
+
+ % if _session['user_group'] == 'admin': + <% playlist_sub_media_type = {'movie': 'video', 'show': 'video', 'artist': 'audio', 'photo': 'photo'} %> +
+ +
+ % endif +
+ +
+
+
+
+
+ + + + + + + + + +
Playlist TitlePlaylist ItemsPlaylist Duration
+
+
+
+
+
+ % if _session['user_group'] == 'admin': +
+
+
+
+
+
+ + Metadata Exports for + ${data['section_name']} + + +
+
+
+ +
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + +
Exported AtMedia TypeRating KeyFilenameFile FormatMetadata LevelMedia Info LevelCustom FieldsFile SizeDownloadDelete
+
+
+
+
+
+ % endif
@@ -335,8 +488,7 @@ DOCUMENTATION :: END <%def name="modalIncludes()"> -
+ <%def name="javascriptIncludes()"> @@ -369,6 +523,9 @@ DOCUMENTATION :: END % if data: <% from plexpy.common import LIVE_TV_SECTION_ID %> +<% + history_user_id = '' if _session['user_group'] == 'admin' else _session['user_id'] +%> + + + +% if _session['user_group'] == 'admin': + +% endif % endif \ No newline at end of file diff --git a/data/interfaces/default/settings.html b/data/interfaces/default/settings.html index 976fe680..28b468c9 100644 --- a/data/interfaces/default/settings.html +++ b/data/interfaces/default/settings.html @@ -17,8 +17,6 @@ <%def name="headerIncludes()"> - - <%def name="body()"> @@ -1457,6 +1455,22 @@ +
+ ${docker_msg | n} +
+
+
+ + + + +
+
+ +
+
+
+

@@ -1986,7 +2000,6 @@ Rating: {rating}/10 --> Rating: /10 <%def name="javascriptIncludes()"> - + + @@ -420,6 +523,8 @@ DOCUMENTATION :: END $.fn.dataTable.tables({ visible: true, api: true }).columns.adjust(); }); + $(".inactive-user-tooltip").tooltip(); + function loadHistoryTable(media_type) { // Build watch history table 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: ' 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() { // Build user sync table sync_table_options.ajax = { @@ -466,6 +614,16 @@ DOCUMENTATION :: END 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() { // Build user IP table user_ip_table_options.ajax = { @@ -483,6 +641,16 @@ DOCUMENTATION :: END 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() { // Build user login table login_log_table_options.ajax = { @@ -504,52 +672,142 @@ DOCUMENTATION :: END 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() { if (typeof(login_log_table) === 'undefined') { 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 () { 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); + } + }); + + }); + +% if _session['user_group'] == 'admin': + % endif +% endif \ No newline at end of file diff --git a/lib/backports/__init__.py b/lib/backports/__init__.py index 69e3be50..de40ea7c 100644 --- a/lib/backports/__init__.py +++ b/lib/backports/__init__.py @@ -1 +1 @@ -__path__ = __import__('pkgutil').extend_path(__path__, __name__) +__import__('pkg_resources').declare_namespace(__name__) diff --git a/lib/backports/csv.py b/lib/backports/csv.py new file mode 100644 index 00000000..4694a28e --- /dev/null +++ b/lib/backports/csv.py @@ -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[^\w\n"\'])(?P ?)(?P["\']).*?(?P=quote)(?P=delim)', # ,".*?", + '(?:^|\n)(?P["\']).*?(?P=quote)(?P[^\w\n"\'])(?P ?)', # ".*?", + '(?P>[^\w\n"\'])(?P ?)(?P["\']).*?(?P=quote)(?:$|\n)', # ,".*?" + '(?:^|\n)(?P["\']).*?(?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 diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index b70aa93c..05a91307 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -36,6 +36,8 @@ class Audio(PlexPartialObject): self.key = data.attrib.get('key') self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) 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.summary = data.attrib.get('summary') self.thumb = data.attrib.get('thumb') @@ -120,17 +122,26 @@ class Artist(Audio): TAG = 'Directory' 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): """ Load attribute values from Plex XML response. """ Audio._loadData(self, data) + self._details_key = self.key + self._include self.art = data.attrib.get('art') self.guid = data.attrib.get('guid') self.key = self.key.replace('/children', '') # FIX_BUG_50 self.locations = self.listAttrs(data, 'path', etag='Location') self.countries = self.findItems(data, media.Country) + self.fields = self.findItems(data, media.Field) self.genres = self.findItems(data, media.Genre) self.similar = self.findItems(data, media.Similar) self.collections = self.findItems(data, media.Collection) + self.moods = self.findItems(data, media.Mood) + self.styles = self.findItems(data, media.Style) def __iter__(self): for album in self.albums(): @@ -217,17 +228,26 @@ class Album(Audio): """ Load attribute values from Plex XML response. """ Audio._loadData(self, data) 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.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.parentGuid = data.attrib.get('parentGuid') self.parentKey = data.attrib.get('parentKey') self.parentRatingKey = data.attrib.get('parentRatingKey') self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') + self.rating = utils.cast(float, data.attrib.get('rating')) 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.genres = self.findItems(data, media.Genre) 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.moods = self.findItems(data, media.Mood) + self.styles = self.findItems(data, media.Style) def track(self, title): """ Returns the :class:`~plexapi.audio.Track` that matches the specified title. @@ -312,20 +332,28 @@ class Track(Audio, Playable): TAG = '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): """ Load attribute values from Plex XML response. """ Audio._loadData(self, data) Playable._loadData(self, data) + self._details_key = self.key + self._include self.art = data.attrib.get('art') self.chapterSource = data.attrib.get('chapterSource') self.duration = utils.cast(int, data.attrib.get('duration')) self.grandparentArt = data.attrib.get('grandparentArt') + self.grandparentGuid = data.attrib.get('grandparentGuid') self.grandparentKey = data.attrib.get('grandparentKey') self.grandparentRatingKey = data.attrib.get('grandparentRatingKey') self.grandparentThumb = data.attrib.get('grandparentThumb') self.grandparentTitle = data.attrib.get('grandparentTitle') self.guid = data.attrib.get('guid') self.originalTitle = data.attrib.get('originalTitle') + self.parentGuid = data.attrib.get('parentGuid') self.parentIndex = data.attrib.get('parentIndex') self.parentKey = data.attrib.get('parentKey') self.parentRatingKey = data.attrib.get('parentRatingKey') @@ -338,6 +366,7 @@ class Track(Audio, Playable): self.year = utils.cast(int, data.attrib.get('year')) self.media = self.findItems(data, media.Media) self.moods = self.findItems(data, media.Mood) + self.fields = self.findItems(data, media.Field) def _prettyfilename(self): """ Returns a filename for use in download. """ @@ -351,6 +380,13 @@ class Track(Audio, Playable): """ Return this track's :class:`~plexapi.audio.Artist`. """ 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): """ Returns str, default title for a new syncItem. """ return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title) diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index e7798459..8f18abec 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -1,5 +1,5 @@ # -*- 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.compat import quote, quote_plus, unquote, urlencode from plexapi.exceptions import BadRequest, NotFound @@ -769,6 +769,11 @@ class MovieSection(LibrarySection): """ Returns a list of collections from this library section. """ 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): """ 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 @@ -849,6 +854,11 @@ class ShowSection(LibrarySection): """ Returns a list of collections from this library section. """ 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): """ 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 @@ -930,6 +940,11 @@ class MusicSection(LibrarySection): """ Returns a list of collections from this library section. """ 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): """ 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 @@ -991,6 +1006,11 @@ class PhotoSection(LibrarySection): """ Search for a photo. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ 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): """ 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 @@ -1092,9 +1112,16 @@ class Collections(PlexObject): def _loadData(self, data): self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) 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.librarySectionID = data.attrib.get('librarySectionID') + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.type = data.attrib.get('type') self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort') self.subtype = data.attrib.get('subtype') self.summary = data.attrib.get('summary') 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.collectionMode = data.attrib.get('collectionMode') self.collectionSort = data.attrib.get('collectionSort') + self.labels = self.findItems(data, media.Label) + self.fields = self.findItems(data, media.Field) @property def children(self): 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): return self.childCount diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 50252e4f..710a4cda 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -5,7 +5,7 @@ import xml from plexapi import compat, log, settings, utils from plexapi.base import PlexObject from plexapi.exceptions import BadRequest -from plexapi.utils import cast +from plexapi.utils import cast, SEARCHTYPES @utils.registerPlexObject @@ -45,6 +45,7 @@ class Media(PlexObject): self.aspectRatio = cast(float, data.attrib.get('aspectRatio')) self.audioChannels = cast(int, data.attrib.get('audioChannels')) self.audioCodec = data.attrib.get('audioCodec') + self.audioProfile = data.attrib.get('videoProfile') self.bitrate = cast(int, data.attrib.get('bitrate')) self.container = data.attrib.get('container') self.duration = cast(int, data.attrib.get('duration')) @@ -60,6 +61,16 @@ class Media(PlexObject): self.videoResolution = data.attrib.get('videoResolution') self.width = cast(int, data.attrib.get('width')) 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): part = self._initpath + '/media/%s' % self.id @@ -96,26 +107,34 @@ class MediaPart(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data + self.audioProfile = data.attrib.get('audioProfile') self.container = data.attrib.get('container') + self.deepAnalysisVersion = cast(int, data.attrib.get('deepAnalysisVersion')) self.duration = cast(int, data.attrib.get('duration')) 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.indexes = data.attrib.get('indexes') self.key = data.attrib.get('key') self.size = cast(int, data.attrib.get('size')) self.decision = data.attrib.get('decision') self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming')) + self.requiredBandwidths = data.attrib.get('requiredBandwidths') self.syncItemId = cast(int, data.attrib.get('syncItemId')) self.syncState = data.attrib.get('syncState') self.videoProfile = data.attrib.get('videoProfile') self.streams = self._buildStreams(data) self.exists = cast(bool, data.attrib.get('exists')) self.accessible = cast(bool, data.attrib.get('accessible')) + + # For Photo only + self.orientation = cast(int, data.attrib.get('orientation')) def _buildStreams(self, data): streams = [] 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): streams.append(cls(self._server, elem, self._initpath)) return streams @@ -132,6 +151,10 @@ class MediaPart(PlexObject): """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """ 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): """ 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). selected (bool): True if this stream is selected. 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. """ @@ -186,18 +210,22 @@ class MediaPartStream(PlexObject): self._data = data self.codec = data.attrib.get('codec') 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.index = cast(int, data.attrib.get('index', '-1')) self.language = data.attrib.get('language') self.languageCode = data.attrib.get('languageCode') self.selected = cast(bool, data.attrib.get('selected', '0')) self.streamType = cast(int, data.attrib.get('streamType')) + self.title = data.attrib.get('title') self.type = cast(int, data.attrib.get('streamType')) @staticmethod def parse(server, data, initpath): # pragma: no cover seems to be dead code. """ 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')) cls = STREAMCLS.get(stype, MediaPartStream) return cls(server, data, initpath) @@ -233,21 +261,31 @@ class VideoStream(MediaPartStream): def _loadData(self, data): """ Load attribute values from Plex XML response. """ super(VideoStream, self)._loadData(data) + self.anamorphic = data.attrib.get('anamorphic') self.bitDepth = cast(int, data.attrib.get('bitDepth')) self.bitrate = cast(int, data.attrib.get('bitrate')) self.cabac = cast(int, data.attrib.get('cabac')) + self.chromaLocation = data.attrib.get('chromaLocation') 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.colorTrc = data.attrib.get('colorTrc') self.duration = cast(int, data.attrib.get('duration')) self.frameRate = cast(float, data.attrib.get('frameRate')) 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.level = cast(int, data.attrib.get('level')) self.profile = data.attrib.get('profile') 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.title = data.attrib.get('title') + self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier')) self.width = cast(int, data.attrib.get('width')) @@ -281,8 +319,20 @@ class AudioStream(MediaPartStream): self.channels = cast(int, data.attrib.get('channels')) self.dialogNorm = cast(int, data.attrib.get('dialogNorm')) 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.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 @@ -303,10 +353,36 @@ class SubtitleStream(MediaPartStream): def _loadData(self, data): """ Load attribute values from Plex XML response. """ super(SubtitleStream, self)._loadData(data) + self.container = data.attrib.get('container') self.forced = cast(bool, data.attrib.get('forced', '0')) self.format = data.attrib.get('format') + self.headerCompression = data.attrib.get('headerCompression') 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 @@ -510,6 +586,29 @@ class MediaTag(PlexObject): 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 class Collection(MediaTag): """ Represents a single Collection media tag. @@ -589,6 +688,12 @@ class Genre(MediaTag): FILTER = 'genre' +@utils.registerPlexObject +class Guid(GuidTag): + """ Represents a single Guid media tag. """ + TAG = "Guid" + + @utils.registerPlexObject class Mood(MediaTag): """ Represents a single Mood media tag. @@ -601,6 +706,18 @@ class Mood(MediaTag): 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 class Poster(PlexObject): """ Represents a Poster. @@ -689,6 +806,7 @@ class Chapter(PlexObject): self.filter = data.attrib.get('filter') # I couldn't filter on it anyways self.tag = data.attrib.get('tag') self.title = self.tag + self.thumb = data.attrib.get('thumb') self.index = cast(int, data.attrib.get('index')) self.start = cast(int, data.attrib.get('startTimeOffset')) self.end = cast(int, data.attrib.get('endTimeOffset')) diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index 66c6d561..c23d7f1d 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -40,12 +40,16 @@ class Photoalbum(PlexPartialObject): 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.ratingKey = data.attrib.get('ratingKey') self.summary = data.attrib.get('summary') self.thumb = data.attrib.get('thumb') self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort') self.type = data.attrib.get('type') self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.fields = self.findItems(data, media.Field) def albums(self, **kwargs): """ Returns a list of :class:`~plexapi.photo.Photoalbum` objects in this album. """ @@ -99,25 +103,43 @@ class Photo(PlexPartialObject): 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): """ Load attribute values from Plex XML response. """ + self.key = data.attrib.get('key') + self._details_key = self.key + self._include self.listType = 'photo' 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.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( 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.parentRatingKey = data.attrib.get('parentRatingKey') + self.parentThumb = data.attrib.get('parentThumb') + self.parentTitle = data.attrib.get('parentTitle') self.ratingKey = data.attrib.get('ratingKey') self.summary = data.attrib.get('summary') self.thumb = data.attrib.get('thumb') self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort') self.type = data.attrib.get('type') self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.year = utils.cast(int, data.attrib.get('year')) self.media = self.findItems(data, media.Media) self.tag = self.findItems(data, media.Tag) + self.fields = self.findItems(data, media.Field) def photoalbum(self): """ Return this photo's :class:`~plexapi.photo.Photoalbum`. """ diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index a23719d5..344622b9 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -23,7 +23,8 @@ log = logging.getLogger('plexapi') # Library Types - Populated at runtime 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, - 'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'userPlaylistItem': 1001} + 'playlist': 15, 'playlistFolder': 16, 'collection': 18, + 'optimizedVersion': 42, 'userPlaylistItem': 1001} PLEXOBJECTS = {} diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 2dcc73a7..a324d039 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -35,6 +35,8 @@ class Video(PlexPartialObject): self.key = data.attrib.get('key', '') self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) 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.summary = data.attrib.get('summary') self.thumb = data.attrib.get('thumb') @@ -264,6 +266,7 @@ class Movie(Playable, Video): directors (List<:class:`~plexapi.media.Director`>): List of director objects. fields (List<:class:`~plexapi.media.Field`>): List of field 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. producers (List<:class:`~plexapi.media.Producer`>): List of producers objects. roles (List<:class:`~plexapi.media.Role`>): List of role objects. @@ -276,7 +279,8 @@ class Movie(Playable, Video): METADATA_TYPE = 'movie' _include = ('?checkFiles=1&includeExtras=1&includeRelated=1' '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1' - '&includeConcerts=1&includePreferences=1') + '&includeConcerts=1&includePreferences=1' + '&indcludeBandwidths=1') def _loadData(self, data): """ Load attribute values from Plex XML response. """ @@ -307,6 +311,7 @@ class Movie(Playable, Video): self.directors = self.findItems(data, media.Director) self.fields = self.findItems(data, media.Field) self.genres = self.findItems(data, media.Genre) + self.guids = self.findItems(data, media.Guid) self.media = self.findItems(data, media.Media) self.producers = self.findItems(data, media.Producer) self.roles = self.findItems(data, media.Role) @@ -415,6 +420,7 @@ class Show(Video): self.theme = data.attrib.get('theme') self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) + self.fields = self.findItems(data, media.Field) self.genres = self.findItems(data, media.Genre) self.roles = self.findItems(data, media.Role) self.labels = self.findItems(data, media.Label) @@ -527,12 +533,19 @@ class Season(Video): Video._loadData(self, data) # fix key if loaded from search 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.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.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.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) + self.fields = self.findItems(data, media.Field) def __repr__(self): return '<%s>' % ':'.join([p for p in [ @@ -644,7 +657,8 @@ class Episode(Playable, Video): _include = ('?checkFiles=1&includeExtras=1&includeRelated=1' '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1' - '&includeConcerts=1&includePreferences=1') + '&includeMarkers=1&includeConcerts=1&includePreferences=1' + '&indcludeBandwidths=1') def _loadData(self, data): """ Load attribute values from Plex XML response. """ @@ -657,6 +671,7 @@ class Episode(Playable, Video): self.contentRating = data.attrib.get('contentRating') self.duration = utils.cast(int, data.attrib.get('duration')) self.grandparentArt = data.attrib.get('grandparentArt') + self.grandparentGuid = data.attrib.get('grandparentGuid') self.grandparentKey = data.attrib.get('grandparentKey') self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) self.grandparentTheme = data.attrib.get('grandparentTheme') @@ -665,6 +680,7 @@ class Episode(Playable, Video): self.guid = data.attrib.get('guid') self.index = utils.cast(int, data.attrib.get('index')) self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.parentGuid = data.attrib.get('parentGuid') self.parentIndex = data.attrib.get('parentIndex') self.parentKey = data.attrib.get('parentKey') 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.year = utils.cast(int, data.attrib.get('year')) self.directors = self.findItems(data, media.Director) + self.fields = self.findItems(data, media.Field) self.media = self.findItems(data, media.Media) self.writers = self.findItems(data, media.Writer) self.labels = self.findItems(data, media.Label) diff --git a/plexpy/__init__.py b/plexpy/__init__.py index 07acbaa9..efeae802 100644 --- a/plexpy/__init__.py +++ b/plexpy/__init__.py @@ -45,7 +45,7 @@ if PYTHON2: import common import database import datafactory - import helpers + import exporter import libraries import logger import mobile_app @@ -65,7 +65,7 @@ else: from plexpy import common from plexpy import database from plexpy import datafactory - from plexpy import helpers + from plexpy import exporter from plexpy import libraries from plexpy import logger from plexpy import mobile_app @@ -226,6 +226,8 @@ def initialize(config_file): CONFIG.BACKUP_DIR, os.path.join(DATA_DIR, 'backups'), 'backups') CONFIG.CACHE_DIR, _ = check_folder_writable( 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, os.path.join(DATA_DIR, 'newsletters'), 'newsletters') @@ -533,6 +535,9 @@ def start(): notification_handler.start_threads(num_threads=CONFIG.NOTIFICATION_THREADS) notifiers.check_browser_enabled() + # Cancel processing exports + exporter.cancel_exports() + if CONFIG.FIRST_RUN_COMPLETE: activity_pinger.connect_server(log=True, startup=True) @@ -789,6 +794,17 @@ def dbcheck(): '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 try: c_db.execute('SELECT started FROM sessions') diff --git a/plexpy/common.py b/plexpy/common.py index c1f37252..5dc239da 100644 --- a/plexpy/common.py +++ b/plexpy/common.py @@ -74,6 +74,9 @@ MEDIA_TYPE_HEADERS = { 'artist': 'Artists', 'album': 'Albums', 'track': 'Tracks', + 'video': 'Videos', + 'audio': 'Tracks', + 'photo': 'Photos' } PLATFORM_NAME_OVERRIDES = { diff --git a/plexpy/config.py b/plexpy/config.py index 4d704071..a4feeca8 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -96,6 +96,7 @@ _CONFIG_DEFINITIONS = { 'CONFIG_VERSION': (int, 'Advanced', 0), 'DO_NOT_OVERRIDE_GIT_BRANCH': (int, 'General', 0), 'ENABLE_HTTPS': (int, 'General', 0), + 'EXPORT_DIR': (str, 'General', ''), 'FIRST_RUN_COMPLETE': (int, 'General', 0), 'FREEZE_DB': (int, 'General', 0), 'GET_FILE_SIZES': (int, 'General', 0), @@ -195,7 +196,7 @@ _WHITELIST_KEYS = ['HTTPS_KEY'] _DO_NOT_IMPORT_KEYS = [ '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_USERNAME', 'HTTP_PASSWORD', 'HTTP_HASH_PASSWORD', 'HTTP_HASHED_PASSWORD', 'ENABLE_HTTPS', 'HTTPS_CREATE_CERT', 'HTTPS_CERT', 'HTTPS_CERT_CHAIN', 'HTTPS_KEY' diff --git a/plexpy/database.py b/plexpy/database.py index 8b0681e2..073c2c68 100644 --- a/plexpy/database.py +++ b/plexpy/database.py @@ -216,6 +216,11 @@ def delete_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): if row_ids and isinstance(row_ids, str): row_ids = list(map(helpers.cast_to_int, row_ids.split(','))) diff --git a/plexpy/datafactory.py b/plexpy/datafactory.py index 939ffc72..60463e59 100644 --- a/plexpy/datafactory.py +++ b/plexpy/datafactory.py @@ -290,8 +290,8 @@ class DataFactory(object): 'recordsTotal': query['totalCount'], 'data': session.friendly_name_to_username(rows), 'draw': query['draw'], - 'filter_duration': helpers.human_duration(filter_duration, sig='dhm'), - 'total_duration': helpers.human_duration(total_duration, sig='dhm') + 'filter_duration': helpers.human_duration(filter_duration, sig='dhm', units='s'), + 'total_duration': helpers.human_duration(total_duration, sig='dhm', units='s') } return dict diff --git a/plexpy/exporter.py b/plexpy/exporter.py new file mode 100644 index 00000000..449fa2d6 --- /dev/null +++ b/plexpy/exporter.py @@ -0,0 +1,2040 @@ +# -*- 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 . + +from __future__ import unicode_literals +from future.builtins import str +from backports import csv + +import json +import os +import requests +import shutil +import threading + +from functools import partial, reduce +from io import open +from multiprocessing.dummy import Pool as ThreadPool + +import plexpy +if plexpy.PYTHON2: + import database + import datatables + import helpers + import logger + import users + from plex import Plex +else: + from plexpy import database + from plexpy import datatables + from plexpy import helpers + from plexpy import logger + from plexpy import users + from plexpy.plex import Plex + + +class Export(object): + # True/False for allowed image export + MEDIA_TYPES = { + 'movie': True, + 'show': True, + 'season': True, + 'episode': False, + 'artist': True, + 'album': True, + 'track': True, + 'photoalbum': False, + 'photo': False, + 'collection': True, + 'playlist': True + } + PLURAL_MEDIA_TYPES = { + 'movie': 'movies', + 'show': 'shows', + 'season': 'seasons', + 'episode': 'episodes', + 'artist': 'artists', + 'album': 'albums', + 'track': 'tracks', + 'phtoalbum': 'photoalbums', + 'photo': 'photos', + 'collection': 'collections', + 'children': 'children', + 'playlist': 'playlists', + 'item': 'items' + } + CHILD_MEDIA_TYPES = { + 'movie': '', + 'show': 'season', + 'season': 'episode', + 'episode': '', + 'artist': 'album', + 'album': 'track', + 'track': '', + 'photoalbum': 'photo', + 'photo': '', + 'collection': 'children', + 'playlist': 'item' + } + METADATA_LEVELS = (0, 1, 2, 3, 9) + MEDIA_INFO_LEVELS = (0, 1, 2, 3, 9) + FILE_FORMATS = ('csv', 'json', 'xml', 'm3u8') + EXPORT_TYPES = ('all', 'collection', 'playlist') + + def __init__(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): + self.section_id = helpers.cast_to_int(section_id) or None + self.user_id = helpers.cast_to_int(user_id) or None + self.rating_key = helpers.cast_to_int(rating_key) or None + self.file_format = str(file_format).lower() + self.metadata_level = helpers.cast_to_int(metadata_level) + self.media_info_level = helpers.cast_to_int(media_info_level) + self.include_thumb = include_thumb + self.include_art = include_art + self.custom_fields = custom_fields.replace(' ', '') + self._custom_fields = {} + self.export_type = export_type or 'all' + + self.timestamp = helpers.timestamp() + + self.media_type = None + self.obj = None + + self.filename = None + self.export_id = None + self.file_size = None + self.success = False + + # Reset export options for m3u8 + if self.file_format == 'm3u8': + self.metadata_level = 1 + self.media_info_level = 1 + self.include_thumb = False + self.include_art = False + self.custom_fields = '' + + def return_attrs(self, media_type, flatten=False): + # o: current object + # e: element in object attribute value list + + def movie_attrs(): + _movie_attrs = { + 'addedAt': helpers.datetime_to_iso, + 'art': None, + 'artFile': lambda o: self.get_image(o, 'art'), + 'audienceRating': None, + 'audienceRatingImage': None, + 'chapters': { + 'id': None, + 'tag': None, + 'index': None, + 'start': None, + 'end': None, + 'thumb': None + }, + 'chapterSource': None, + 'collections': { + 'id': None, + 'tag': None + }, + 'contentRating': None, + 'countries': { + 'id': None, + 'tag': None + }, + 'directors': { + 'id': None, + 'tag': None + }, + 'duration': None, + 'durationHuman': lambda o: helpers.human_duration(getattr(o, 'duration', 0), sig='dhm'), + 'fields': { + 'name': None, + 'locked': None + }, + 'genres': { + 'id': None, + 'tag': None + }, + 'guid': None, + 'guids': { + 'id': None + }, + 'key': None, + 'labels': { + 'id': None, + 'tag': None + }, + 'lastViewedAt': helpers.datetime_to_iso, + 'librarySectionID': None, + 'librarySectionKey': None, + 'librarySectionTitle': None, + 'locations': None, + 'media': { + 'aspectRatio': None, + 'audioChannels': None, + 'audioCodec': None, + 'audioProfile': None, + 'bitrate': None, + 'container': None, + 'duration': None, + 'height': None, + 'id': None, + 'has64bitOffsets': None, + 'optimizedForStreaming': None, + 'optimizedVersion': None, + 'target': None, + 'title': None, + 'videoCodec': None, + 'videoFrameRate': None, + 'videoProfile': None, + 'videoResolution': None, + 'width': None, + 'hdr': lambda o: self.get_any_hdr(o, 'movie'), + 'parts': { + 'accessible': None, + 'audioProfile': None, + 'container': None, + 'deepAnalysisVersion': None, + 'duration': None, + 'exists': None, + 'file': None, + 'has64bitOffsets': None, + 'id': None, + 'indexes': None, + 'key': None, + 'size': None, + 'sizeHuman': lambda o: helpers.human_file_size(getattr(o, 'size', 0)), + 'optimizedForStreaming': None, + 'requiredBandwidths': None, + 'syncItemId': None, + 'syncState': None, + 'videoProfile': None, + 'videoStreams': { + 'codec': None, + 'codecID': None, + 'default': None, + 'displayTitle': None, + 'extendedDisplayTitle': None, + 'id': None, + 'index': None, + 'language': None, + 'languageCode': None, + 'selected': None, + 'streamType': None, + 'title': None, + 'type': None, + 'bitDepth': None, + 'bitrate': None, + 'cabac': None, + 'chromaLocation': None, + 'chromaSubsampling': None, + 'colorPrimaries': None, + 'colorRange': None, + 'colorSpace': None, + 'colorTrc': None, + 'duration': None, + 'frameRate': None, + 'frameRateMode': None, + 'hasScalingMatrix': None, + 'hdr': lambda o: helpers.is_hdr(getattr(o, 'bitDepth', 0), getattr(o, 'colorSpace', '')), + 'height': None, + 'level': None, + 'pixelAspectRatio': None, + 'pixelFormat': None, + 'profile': None, + 'refFrames': None, + 'requiredBandwidths': None, + 'scanType': None, + 'streamIdentifier': None, + 'width': None + }, + 'audioStreams': { + 'codec': None, + 'codecID': None, + 'default': None, + 'displayTitle': None, + 'extendedDisplayTitle': None, + 'id': None, + 'index': None, + 'language': None, + 'languageCode': None, + 'selected': None, + 'streamType': None, + 'title': None, + 'type': None, + 'audioChannelLayout': None, + 'bitDepth': None, + 'bitrate': None, + 'bitrateMode': None, + 'channels': None, + 'dialogNorm': None, + 'duration': None, + 'profile': None, + 'requiredBandwidths': None, + 'samplingRate': None + }, + 'subtitleStreams': { + 'codec': None, + 'codecID': None, + 'default': None, + 'displayTitle': None, + 'extendedDisplayTitle': None, + 'id': None, + 'index': None, + 'language': None, + 'languageCode': None, + 'requiredBandwidths': None, + 'selected': None, + 'streamType': None, + 'title': None, + 'type': None, + 'forced': None, + 'format': None, + 'headerCompression': None, + 'key': None + } + } + }, + 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True), + 'originalTitle': None, + 'producers': { + 'id': None, + 'tag': None + }, + 'rating': None, + 'ratingImage': None, + 'ratingKey': None, + 'roles': { + 'id': None, + 'tag': None, + 'role': None, + 'thumb': None + }, + 'studio': None, + 'summary': None, + 'tagline': None, + 'thumb': None, + 'thumbFile': lambda o: self.get_image(o, 'thumb'), + 'title': None, + 'titleSort': None, + 'type': None, + 'updatedAt': helpers.datetime_to_iso, + 'userRating': None, + 'viewCount': None, + 'writers': { + 'id': None, + 'tag': None + }, + 'year': None + } + return _movie_attrs + + def show_attrs(): + _show_attrs = { + 'addedAt': helpers.datetime_to_iso, + 'art': None, + 'artFile': lambda o: self.get_image(o, 'art'), + 'banner': None, + 'childCount': None, + 'collections': { + 'id': None, + 'tag': None + }, + 'contentRating': None, + 'duration': None, + 'durationHuman': lambda o: helpers.human_duration(getattr(o, 'duration', 0), sig='dhm'), + 'fields': { + 'name': None, + 'locked': None + }, + 'genres': { + 'id': None, + 'tag': None + }, + 'guid': None, + 'index': None, + 'key': None, + 'labels': { + 'id': None, + 'tag': None + }, + 'lastViewedAt': helpers.datetime_to_iso, + 'leafCount': None, + 'librarySectionID': None, + 'librarySectionKey': None, + 'librarySectionTitle': None, + 'locations': None, + 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True), + 'rating': None, + 'ratingKey': None, + 'roles': { + 'id': None, + 'tag': None, + 'role': None, + 'thumb': None + }, + 'studio': None, + 'summary': None, + 'theme': None, + 'thumb': None, + 'thumbFile': lambda o: self.get_image(o, 'thumb'), + 'title': None, + 'titleSort': None, + 'type': None, + 'updatedAt': helpers.datetime_to_iso, + 'userRating': None, + 'viewCount': None, + 'viewedLeafCount': None, + 'year': None, + 'seasons': lambda e: self._export_obj(e) + } + return _show_attrs + + def season_attrs(): + _season_attrs = { + 'addedAt': helpers.datetime_to_iso, + 'art': None, + 'fields': { + 'name': None, + 'locked': None + }, + 'guid': None, + 'index': None, + 'key': None, + 'lastViewedAt': helpers.datetime_to_iso, + 'leafCount': None, + 'librarySectionID': None, + 'librarySectionKey': None, + 'librarySectionTitle': None, + 'parentGuid': None, + 'parentIndex': None, + 'parentKey': None, + 'parentRatingKey': None, + 'parentTheme': None, + 'parentThumb': None, + 'parentTitle': None, + 'ratingKey': None, + 'summary': None, + 'thumb': None, + 'thumbFile': lambda o: self.get_image(o, 'thumb'), + 'title': None, + 'titleSort': None, + 'type': None, + 'updatedAt': helpers.datetime_to_iso, + 'userRating': None, + 'viewCount': None, + 'viewedLeafCount': None, + 'episodes': lambda e: self._export_obj(e) + } + return _season_attrs + + def episode_attrs(): + _episode_attrs = { + 'addedAt': helpers.datetime_to_iso, + 'art': None, + 'chapterSource': None, + 'contentRating': None, + 'directors': { + 'id': None, + 'tag': None + }, + 'duration': None, + 'durationHuman': lambda o: helpers.human_duration(getattr(o, 'duration', 0), sig='dhm'), + 'fields': { + 'name': None, + 'locked': None + }, + 'grandparentArt': None, + 'grandparentGuid': None, + 'grandparentKey': None, + 'grandparentRatingKey': None, + 'grandparentTheme': None, + 'grandparentThumb': None, + 'grandparentTitle': None, + 'guid': None, + 'index': None, + 'key': None, + 'lastViewedAt': helpers.datetime_to_iso, + 'librarySectionID': None, + 'librarySectionKey': None, + 'librarySectionTitle': None, + 'locations': None, + 'media': { + 'aspectRatio': None, + 'audioChannels': None, + 'audioCodec': None, + 'audioProfile': None, + 'bitrate': None, + 'container': None, + 'duration': None, + 'height': None, + 'id': None, + 'has64bitOffsets': None, + 'optimizedForStreaming': None, + 'optimizedVersion': None, + 'target': None, + 'title': None, + 'videoCodec': None, + 'videoFrameRate': None, + 'videoProfile': None, + 'videoResolution': None, + 'width': None, + 'hdr': lambda o: self.get_any_hdr(o, 'episode'), + 'parts': { + 'accessible': None, + 'audioProfile': None, + 'container': None, + 'deepAnalysisVersion': None, + 'duration': None, + 'exists': None, + 'file': None, + 'has64bitOffsets': None, + 'id': None, + 'indexes': None, + 'key': None, + 'size': None, + 'sizeHuman': lambda o: helpers.human_file_size(getattr(o, 'size', 0)), + 'optimizedForStreaming': None, + 'requiredBandwidths': None, + 'syncItemId': None, + 'syncState': None, + 'videoProfile': None, + 'videoStreams': { + 'codec': None, + 'codecID': None, + 'default': None, + 'displayTitle': None, + 'extendedDisplayTitle': None, + 'id': None, + 'index': None, + 'language': None, + 'languageCode': None, + 'selected': None, + 'streamType': None, + 'title': None, + 'type': None, + 'bitDepth': None, + 'bitrate': None, + 'cabac': None, + 'chromaLocation': None, + 'chromaSubsampling': None, + 'colorPrimaries': None, + 'colorRange': None, + 'colorSpace': None, + 'colorTrc': None, + 'duration': None, + 'frameRate': None, + 'frameRateMode': None, + 'hasScalingMatrix': None, + 'hdr': lambda o: helpers.is_hdr(getattr(o, 'bitDepth', 0), getattr(o, 'colorSpace', '')), + 'height': None, + 'level': None, + 'pixelAspectRatio': None, + 'pixelFormat': None, + 'profile': None, + 'refFrames': None, + 'requiredBandwidths': None, + 'scanType': None, + 'streamIdentifier': None, + 'width': None + }, + 'audioStreams': { + 'codec': None, + 'codecID': None, + 'default': None, + 'displayTitle': None, + 'extendedDisplayTitle': None, + 'id': None, + 'index': None, + 'language': None, + 'languageCode': None, + 'selected': None, + 'streamType': None, + 'title': None, + 'type': None, + 'audioChannelLayout': None, + 'bitDepth': None, + 'bitrate': None, + 'bitrateMode': None, + 'channels': None, + 'dialogNorm': None, + 'duration': None, + 'profile': None, + 'requiredBandwidths': None, + 'samplingRate': None + }, + 'subtitleStreams': { + 'codec': None, + 'codecID': None, + 'default': None, + 'displayTitle': None, + 'extendedDisplayTitle': None, + 'id': None, + 'index': None, + 'language': None, + 'languageCode': None, + 'requiredBandwidths': None, + 'selected': None, + 'streamType': None, + 'title': None, + 'type': None, + 'forced': None, + 'format': None, + 'headerCompression': None, + 'key': None + } + } + }, + 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True), + 'parentGuid': None, + 'parentIndex': None, + 'parentKey': None, + 'parentRatingKey': None, + 'parentThumb': None, + 'parentTitle': None, + 'rating': None, + 'ratingKey': None, + 'summary': None, + 'thumb': None, + 'title': None, + 'titleSort': None, + 'type': None, + 'updatedAt': helpers.datetime_to_iso, + 'userRating': None, + 'viewCount': None, + 'writers': { + 'id': None, + 'tag': None + }, + 'year': None + } + return _episode_attrs + + def artist_attrs(): + _artist_attrs = { + 'addedAt': helpers.datetime_to_iso, + 'art': None, + 'artFile': lambda o: self.get_image(o, 'art'), + 'collections': { + 'id': None, + 'tag': None + }, + 'countries': { + 'id': None, + 'tag': None + }, + 'fields': { + 'name': None, + 'locked': None + }, + 'genres': { + 'id': None, + 'tag': None + }, + 'guid': None, + 'index': None, + 'key': None, + 'lastViewedAt': helpers.datetime_to_iso, + 'librarySectionID': None, + 'librarySectionKey': None, + 'librarySectionTitle': None, + 'locations': None, + 'moods': { + 'id': None, + 'tag': None + }, + 'rating': None, + 'ratingKey': None, + 'styles': { + 'id': None, + 'tag': None + }, + 'summary': None, + 'thumb': None, + 'thumbFile': lambda o: self.get_image(o, 'thumb'), + 'title': None, + 'titleSort': None, + 'type': None, + 'updatedAt': helpers.datetime_to_iso, + 'userRating': None, + 'viewCount': None, + 'albums': lambda e: self._export_obj(e) + } + return _artist_attrs + + def album_attrs(): + _album_attrs = { + 'addedAt': helpers.datetime_to_iso, + 'art': None, + 'artFile': lambda o: self.get_image(o, 'art'), + 'collections': { + 'id': None, + 'tag': None + }, + 'fields': { + 'name': None, + 'locked': None + }, + 'genres': { + 'id': None, + 'tag': None + }, + 'guid': None, + 'index': None, + 'key': None, + 'labels': { + 'id': None, + 'tag': None + }, + 'lastViewedAt': helpers.datetime_to_iso, + 'leafCount': None, + 'librarySectionID': None, + 'librarySectionKey': None, + 'librarySectionTitle': None, + 'loudnessAnalysisVersion': None, + 'moods': { + 'id': None, + 'tag': None + }, + 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True), + 'parentGuid': None, + 'parentKey': None, + 'parentRatingKey': None, + 'parentThumb': None, + 'parentTitle': None, + 'rating': None, + 'ratingKey': None, + 'styles': { + 'id': None, + 'tag': None + }, + 'summary': None, + 'thumb': None, + 'thumbFile': lambda o: self.get_image(o, 'thumb'), + 'title': None, + 'titleSort': None, + 'type': None, + 'updatedAt': helpers.datetime_to_iso, + 'userRating': None, + 'viewCount': None, + 'viewedLeafCount': None, + 'tracks': lambda e: self._export_obj(e) + } + return _album_attrs + + def track_attrs(): + _track_attrs = { + 'addedAt': helpers.datetime_to_iso, + 'art': None, + 'duration': None, + 'durationHuman': lambda o: helpers.human_duration(getattr(o, 'duration', 0), sig='dhm'), + 'fields': { + 'name': None, + 'locked': None + }, + 'grandparentArt': None, + 'grandparentGuid': None, + 'grandparentKey': None, + 'grandparentRatingKey': None, + 'grandparentThumb': None, + 'grandparentTitle': None, + 'guid': None, + 'index': None, + 'key': None, + 'lastViewedAt': helpers.datetime_to_iso, + 'librarySectionID': None, + 'librarySectionKey': None, + 'librarySectionTitle': None, + 'locations': None, + 'media': { + 'audioChannels': None, + 'audioCodec': None, + 'audioProfile': None, + 'bitrate': None, + 'container': None, + 'duration': None, + 'id': None, + 'title': None, + 'parts': { + 'accessible': None, + 'audioProfile': None, + 'container': None, + 'deepAnalysisVersion': None, + 'duration': None, + 'exists': None, + 'file': None, + 'hasThumbnail': None, + 'id': None, + 'key': None, + 'size': None, + 'sizeHuman': lambda o: helpers.human_file_size(getattr(o, 'size', 0)), + 'requiredBandwidths': None, + 'syncItemId': None, + 'syncState': None, + 'audioStreams': { + 'codec': None, + 'codecID': None, + 'default': None, + 'displayTitle': None, + 'extendedDisplayTitle': None, + 'id': None, + 'index': None, + 'selected': None, + 'streamType': None, + 'title': None, + 'type': None, + 'albumGain': None, + 'albumPeak': None, + 'albumRange': None, + 'audioChannelLayout': None, + 'bitrate': None, + 'channels': None, + 'duration': None, + 'endRamp': None, + 'gain': None, + 'loudness': None, + 'lra': None, + 'peak': None, + 'requiredBandwidths': None, + 'samplingRate': None, + 'startRamp': None, + }, + 'lyricStreams': { + 'codec': None, + 'codecID': None, + 'default': None, + 'displayTitle': None, + 'extendedDisplayTitle': None, + 'id': None, + 'index': None, + 'minLines': None, + 'provider': None, + 'streamType': None, + 'timed': None, + 'title': None, + 'type': None, + 'format': None, + 'key': None + } + } + }, + 'moods': { + 'id': None, + 'tag': None + }, + 'originalTitle': None, + 'parentGuid': None, + 'parentIndex': None, + 'parentKey': None, + 'parentRatingKey': None, + 'parentThumb': None, + 'parentTitle': None, + 'ratingCount': None, + 'ratingKey': None, + 'summary': None, + 'thumb': None, + 'title': None, + 'titleSort': None, + 'type': None, + 'updatedAt': helpers.datetime_to_iso, + 'userRating': None, + 'viewCount': None, + 'year': None, + } + return _track_attrs + + def photo_album_attrs(): + _photo_album_attrs = { + # For some reason photos needs to be first, + # otherwise the photo album ratingKey gets + # clobbered by the first photo's ratingKey + 'photos': lambda e: self._export_obj(e), + 'addedAt': helpers.datetime_to_iso, + 'art': None, + 'composite': None, + 'fields': { + 'name': None, + 'locked': None + }, + 'guid': None, + 'index': None, + 'key': None, + 'librarySectionID': None, + 'librarySectionKey': None, + 'librarySectionTitle': None, + 'ratingKey': None, + 'summary': None, + 'thumb': None, + 'title': None, + 'titleSort': None, + 'type': None, + 'updatedAt': helpers.datetime_to_iso + } + return _photo_album_attrs + + def photo_attrs(): + _photo_attrs = { + 'addedAt': helpers.datetime_to_iso, + 'createdAtAccuracy': None, + 'createdAtTZOffset': None, + 'fields': { + 'name': None, + 'locked': None + }, + 'guid': None, + 'index': None, + 'key': None, + 'librarySectionID': None, + 'librarySectionKey': None, + 'librarySectionTitle': None, + 'originallyAvailableAt': partial(helpers.datetime_to_iso, to_date=True), + 'parentGuid': None, + 'parentIndex': None, + 'parentKey': None, + 'parentRatingKey': None, + 'parentThumb': None, + 'parentTitle': None, + 'ratingKey': None, + 'summary': None, + 'thumb': None, + 'title': None, + 'titleSort': None, + 'type': None, + 'updatedAt': helpers.datetime_to_iso, + 'year': None, + 'media': { + 'aperture': None, + 'aspectRatio': None, + 'container': None, + 'height': None, + 'id': None, + 'iso': None, + 'lens': None, + 'make': None, + 'model': None, + 'width': None, + 'parts': { + 'accessible': None, + 'container': None, + 'exists': None, + 'file': None, + 'id': None, + 'key': None, + 'size': None, + 'sizeHuman': lambda o: helpers.human_file_size(getattr(o, 'size', 0)), + } + }, + 'tag': { + 'id': None, + 'tag': None, + 'title': None + } + } + return _photo_attrs + + def collection_attrs(): + _collection_attrs = { + 'addedAt': helpers.datetime_to_iso, + 'art': None, + 'artFile': lambda o: self.get_image(o, 'art'), + 'childCount': None, + 'collectionMode': None, + 'collectionSort': None, + 'contentRating': None, + 'fields': { + 'name': None, + 'locked': None + }, + 'guid': None, + 'index': None, + 'key': None, + 'labels': { + 'id': None, + 'tag': None + }, + 'librarySectionID': None, + 'librarySectionKey': None, + 'librarySectionTitle': None, + 'maxYear': None, + 'minYear': None, + 'ratingKey': None, + 'subtype': None, + 'summary': None, + 'thumb': None, + 'thumbFile': lambda o: self.get_image(o, 'thumb'), + 'title': None, + 'titleSort': None, + 'type': None, + 'updatedAt': helpers.datetime_to_iso, + 'children': lambda e: self._export_obj(e) + } + return _collection_attrs + + def playlist_attrs(): + _playlist_attrs = { + 'addedAt': helpers.datetime_to_iso, + 'composite': None, + 'duration': None, + 'durationHuman': lambda o: helpers.human_duration(getattr(o, 'duration', 0), sig='dhm'), + 'guid': None, + 'key': None, + 'leafCount': None, + 'playlistType': None, + 'ratingKey': None, + 'smart': None, + 'summary': None, + 'title': None, + 'type': None, + 'updatedAt': helpers.datetime_to_iso, + 'items': lambda e: self._export_obj(e) + } + return _playlist_attrs + + _media_types = { + 'movie': movie_attrs, + 'show': show_attrs, + 'season': season_attrs, + 'episode': episode_attrs, + 'artist': artist_attrs, + 'album': album_attrs, + 'track': track_attrs, + 'photoalbum': photo_album_attrs, + 'photo': photo_attrs, + 'collection': collection_attrs, + 'playlist': playlist_attrs, + } + + media_attrs = _media_types[media_type]() + + if flatten: + media_attrs = helpers.flatten_dict(media_attrs)[0] + + return media_attrs + + def return_levels(self, media_type, reverse_map=False): + def movie_levels(): + _media_type = 'movie' + _metadata_levels = { + 1: [ + 'ratingKey', 'title', 'titleSort', 'originalTitle', 'originallyAvailableAt', 'year', 'addedAt', + 'rating', 'ratingImage', 'audienceRating', 'audienceRatingImage', 'userRating', 'contentRating', + 'studio', 'tagline', 'summary', 'guid', 'duration', 'durationHuman', 'type' + ], + 2: [ + 'directors.tag', 'writers.tag', 'producers.tag', 'roles.tag', 'roles.role', + 'countries.tag', 'genres.tag', 'collections.tag', 'labels.tag', + 'fields.name', 'fields.locked', 'guids.id' + ], + 3: [ + 'art', 'thumb', 'key', 'chapterSource', + 'chapters.tag', 'chapters.index', 'chapters.start', 'chapters.end', 'chapters.thumb', + 'updatedAt', 'lastViewedAt', 'viewCount' + ], + 9: self._get_all_metadata_attrs(_media_type) + } + _media_info_levels = { + 1: [ + 'locations', 'media.aspectRatio', 'media.audioChannels', 'media.audioCodec', 'media.audioProfile', + 'media.bitrate', 'media.container', 'media.duration', 'media.height', 'media.width', + 'media.videoCodec', 'media.videoFrameRate', 'media.videoProfile', 'media.videoResolution', + 'media.optimizedVersion', 'media.hdr' + ], + 2: [ + 'media.parts.accessible', 'media.parts.exists', 'media.parts.file', 'media.parts.duration', + 'media.parts.container', 'media.parts.indexes', 'media.parts.size', 'media.parts.sizeHuman', + 'media.parts.audioProfile', 'media.parts.videoProfile', + 'media.parts.optimizedForStreaming', 'media.parts.deepAnalysisVersion' + ], + 3: [ + 'media.parts.videoStreams.codec', 'media.parts.videoStreams.bitrate', + 'media.parts.videoStreams.language', 'media.parts.videoStreams.languageCode', + 'media.parts.videoStreams.title', 'media.parts.videoStreams.displayTitle', + 'media.parts.videoStreams.extendedDisplayTitle', 'media.parts.videoStreams.hdr', + 'media.parts.videoStreams.bitDepth', 'media.parts.videoStreams.colorSpace', + 'media.parts.videoStreams.frameRate', 'media.parts.videoStreams.level', + 'media.parts.videoStreams.profile', 'media.parts.videoStreams.refFrames', + 'media.parts.videoStreams.scanType', 'media.parts.videoStreams.default', + 'media.parts.videoStreams.height', 'media.parts.videoStreams.width', + 'media.parts.audioStreams.codec', 'media.parts.audioStreams.bitrate', + 'media.parts.audioStreams.language', 'media.parts.audioStreams.languageCode', + 'media.parts.audioStreams.title', 'media.parts.audioStreams.displayTitle', + 'media.parts.audioStreams.extendedDisplayTitle', 'media.parts.audioStreams.bitDepth', + 'media.parts.audioStreams.channels', 'media.parts.audioStreams.audioChannelLayout', + 'media.parts.audioStreams.profile', 'media.parts.audioStreams.samplingRate', + 'media.parts.audioStreams.default', + 'media.parts.subtitleStreams.codec', 'media.parts.subtitleStreams.format', + 'media.parts.subtitleStreams.language', 'media.parts.subtitleStreams.languageCode', + 'media.parts.subtitleStreams.title', 'media.parts.subtitleStreams.displayTitle', + 'media.parts.subtitleStreams.extendedDisplayTitle', 'media.parts.subtitleStreams.forced', + 'media.parts.subtitleStreams.default' + ], + 9: [ + 'locations', 'media' + ] + } + return _metadata_levels, _media_info_levels + + def show_levels(): + _media_type = 'show' + _metadata_levels = { + 1: [ + 'ratingKey', 'title', 'titleSort', 'originallyAvailableAt', 'year', 'addedAt', + 'rating', 'userRating', 'contentRating', + 'studio', 'summary', 'guid', 'duration', 'durationHuman', 'type', 'childCount', + 'seasons' + ], + 2: [ + 'roles.tag', 'roles.role', + 'genres.tag', 'collections.tag', 'labels.tag', + 'fields.name', 'fields.locked' + ], + 3: [ + 'art', 'thumb', 'banner', 'theme', 'key', + 'updatedAt', 'lastViewedAt', 'viewCount' + ], + 9: self._get_all_metadata_attrs(_media_type) + } + _media_info_levels = {} + return _metadata_levels, _media_info_levels + + def season_levels(): + _media_type = 'season' + _metadata_levels = { + 1: [ + 'ratingKey', 'title', 'titleSort', 'addedAt', + 'userRating', + 'summary', 'guid', 'type', 'index', + 'parentTitle', 'parentRatingKey', 'parentGuid', + 'episodes' + ], + 2: [ + 'fields.name', 'fields.locked' + ], + 3: [ + 'art', 'thumb', 'key', + 'updatedAt', 'lastViewedAt', 'viewCount', + 'parentKey', 'parentTheme', 'parentThumb' + ], + 9: self._get_all_metadata_attrs(_media_type) + } + _media_info_levels = {} + return _metadata_levels, _media_info_levels + + def episode_levels(): + _media_type = 'episode' + _metadata_levels = { + 1: [ + 'ratingKey', 'title', 'titleSort', 'originallyAvailableAt', 'year', 'addedAt', + 'rating', 'userRating', 'contentRating', + 'summary', 'guid', 'duration', 'durationHuman', 'type', 'index', + 'parentTitle', 'parentRatingKey', 'parentGuid', 'parentIndex', + 'grandparentTitle', 'grandparentRatingKey', 'grandparentGuid' + ], + 2: [ + 'directors.tag', 'writers.tag', + 'fields.name', 'fields.locked' + ], + 3: [ + 'art', 'thumb', 'key', 'chapterSource', + 'updatedAt', 'lastViewedAt', 'viewCount', + 'parentThumb', 'parentKey', + 'grandparentArt', 'grandparentThumb', 'grandparentTheme', 'grandparentKey' + ], + 9: self._get_all_metadata_attrs(_media_type) + } + _media_info_levels = { + 1: [ + 'locations', 'media.aspectRatio', 'media.audioChannels', 'media.audioCodec', 'media.audioProfile', + 'media.bitrate', 'media.container', 'media.duration', 'media.height', 'media.width', + 'media.videoCodec', 'media.videoFrameRate', 'media.videoProfile', 'media.videoResolution', + 'media.optimizedVersion', 'media.hdr' + ], + 2: [ + 'media.parts.accessible', 'media.parts.exists', 'media.parts.file', 'media.parts.duration', + 'media.parts.container', 'media.parts.indexes', 'media.parts.size', 'media.parts.sizeHuman', + 'media.parts.audioProfile', 'media.parts.videoProfile', + 'media.parts.optimizedForStreaming', 'media.parts.deepAnalysisVersion' + ], + 3: [ + 'media.parts.videoStreams.codec', 'media.parts.videoStreams.bitrate', + 'media.parts.videoStreams.language', 'media.parts.videoStreams.languageCode', + 'media.parts.videoStreams.title', 'media.parts.videoStreams.displayTitle', + 'media.parts.videoStreams.extendedDisplayTitle', 'media.parts.videoStreams.hdr', + 'media.parts.videoStreams.bitDepth', 'media.parts.videoStreams.colorSpace', + 'media.parts.videoStreams.frameRate', 'media.parts.videoStreams.level', + 'media.parts.videoStreams.profile', 'media.parts.videoStreams.refFrames', + 'media.parts.videoStreams.scanType', 'media.parts.videoStreams.default', + 'media.parts.videoStreams.height', 'media.parts.videoStreams.width', + 'media.parts.audioStreams.codec', 'media.parts.audioStreams.bitrate', + 'media.parts.audioStreams.language', 'media.parts.audioStreams.languageCode', + 'media.parts.audioStreams.title', 'media.parts.audioStreams.displayTitle', + 'media.parts.audioStreams.extendedDisplayTitle', 'media.parts.audioStreams.bitDepth', + 'media.parts.audioStreams.channels', 'media.parts.audioStreams.audioChannelLayout', + 'media.parts.audioStreams.profile', 'media.parts.audioStreams.samplingRate', + 'media.parts.audioStreams.default', + 'media.parts.subtitleStreams.codec', 'media.parts.subtitleStreams.format', + 'media.parts.subtitleStreams.language', 'media.parts.subtitleStreams.languageCode', + 'media.parts.subtitleStreams.title', 'media.parts.subtitleStreams.displayTitle', + 'media.parts.subtitleStreams.extendedDisplayTitle', 'media.parts.subtitleStreams.forced', + 'media.parts.subtitleStreams.default' + ], + 9: [ + 'locations', 'media' + ] + } + return _metadata_levels, _media_info_levels + + def artist_levels(): + _media_type = 'artist' + _metadata_levels = { + 1: [ + 'ratingKey', 'title', 'titleSort', 'addedAt', + 'rating', 'userRating', + 'summary', 'guid', 'type', + 'albums' + ], + 2: [ + 'collections.tag', 'genres.tag', 'countries.tag', 'moods.tag', 'styles.tag', + 'fields.name', 'fields.locked' + ], + 3: [ + 'art', 'thumb', 'key', + 'updatedAt', 'lastViewedAt', 'viewCount' + ], + 9: self._get_all_metadata_attrs(_media_type) + } + _media_info_levels = {} + return _metadata_levels, _media_info_levels + + def album_levels(): + _media_type = 'album' + _metadata_levels = { + 1: [ + 'ratingKey', 'title', 'titleSort', 'originallyAvailableAt', 'addedAt', + 'rating', 'userRating', + 'summary', 'guid', 'type', 'index', + 'parentTitle', 'parentRatingKey', 'parentGuid', + 'tracks' + ], + 2: [ + 'collections.tag', 'genres.tag', 'labels.tag', 'moods.tag', 'styles.tag', + 'fields.name', 'fields.locked' + ], + 3: [ + 'art', 'thumb', 'key', + 'updatedAt', 'lastViewedAt', 'viewCount', + 'parentKey', 'parentThumb' + ], + 9: self._get_all_metadata_attrs(_media_type) + } + _media_info_levels = {} + return _metadata_levels, _media_info_levels + + def track_levels(): + _media_type = 'track' + _metadata_levels = { + 1: [ + 'ratingKey', 'title', 'titleSort', 'originalTitle', 'year', 'addedAt', + 'userRating', 'ratingCount', + 'summary', 'guid', 'duration', 'durationHuman', 'type', 'index', + 'parentTitle', 'parentRatingKey', 'parentGuid', 'parentIndex', + 'grandparentTitle', 'grandparentRatingKey', 'grandparentGuid' + ], + 2: [ + 'moods.tag', 'writers.tag', + 'fields.name', 'fields.locked' + ], + 3: [ + 'art', 'thumb', 'key', + 'updatedAt', 'lastViewedAt', 'viewCount', + 'parentThumb', 'parentKey', + 'grandparentArt', 'grandparentThumb', 'grandparentKey' + ], + 9: self._get_all_metadata_attrs(_media_type) + } + _media_info_levels = { + 1: [ + 'locations', 'media.audioChannels', 'media.audioCodec', + 'media.audioProfile', + 'media.bitrate', 'media.container', 'media.duration' + ], + 2: [ + 'media.parts.accessible', 'media.parts.exists', 'media.parts.file', 'media.parts.duration', + 'media.parts.container', 'media.parts.size', 'media.parts.sizeHuman', + 'media.parts.audioProfile', + 'media.parts.deepAnalysisVersion', 'media.parts.hasThumbnail' + ], + 3: [ + 'media.parts.audioStreams.codec', 'media.parts.audioStreams.bitrate', + 'media.parts.audioStreams.title', 'media.parts.audioStreams.displayTitle', + 'media.parts.audioStreams.extendedDisplayTitle', + 'media.parts.audioStreams.channels', 'media.parts.audioStreams.audioChannelLayout', + 'media.parts.audioStreams.samplingRate', + 'media.parts.audioStreams.default', + 'media.parts.audioStreams.albumGain', 'media.parts.audioStreams.albumPeak', + 'media.parts.audioStreams.albumRange', + 'media.parts.audioStreams.loudness', 'media.parts.audioStreams.gain', + 'media.parts.audioStreams.lra', 'media.parts.audioStreams.peak', + 'media.parts.audioStreams.startRamp', 'media.parts.audioStreams.endRamp', + 'media.parts.lyricStreams.codec', 'media.parts.lyricStreams.format', + 'media.parts.lyricStreams.title', 'media.parts.lyricStreams.displayTitle', + 'media.parts.lyricStreams.extendedDisplayTitle', + 'media.parts.lyricStreams.default', 'media.parts.lyricStreams.minLines', + 'media.parts.lyricStreams.provider', 'media.parts.lyricStreams.timed', + ], + 9: [ + 'locations', 'media' + ] + } + return _metadata_levels, _media_info_levels + + def photo_album_levels(): + _media_type = 'photoalbum' + _metadata_levels = { + 1: [ + 'ratingKey', 'title', 'titleSort', 'addedAt', + 'summary', 'guid', 'type', 'index', + 'photos' + ], + 2: [ + 'fields.name', 'fields.locked' + ], + 3: [ + 'art', 'thumb', 'key', + 'updatedAt' + ], + 9: self._get_all_metadata_attrs(_media_type) + } + _media_info_levels = {} + return _metadata_levels, _media_info_levels + + def photo_levels(): + _media_type = 'photo' + _metadata_levels = { + 1: [ + 'ratingKey', 'title', 'titleSort', 'year', 'originallyAvailableAt', 'addedAt', + 'summary', 'guid', 'type', 'index', + 'parentTitle', 'parentRatingKey', 'parentGuid', 'parentIndex', + 'createdAtAccuracy', 'createdAtTZOffset' + ], + 2: [ + 'tag.tag', 'tag.title' + ], + 3: [ + 'thumb', 'key', + 'updatedAt', + 'parentThumb', 'parentKey' + ], + 9: self._get_all_metadata_attrs(_media_type) + } + _media_info_levels = { + 1: [ + 'media.aspectRatio', 'media.aperture', + 'media.container', 'media.height', 'media.width', + 'media.iso', 'media.lens', 'media.make', 'media.model' + ], + 2: [ + 'media.parts.accessible', 'media.parts.exists', 'media.parts.file', + 'media.parts.container', 'media.parts.size', 'media.parts.sizeHuman' + ], + 3: [ + ], + 9: [ + 'media' + ] + } + return _metadata_levels, _media_info_levels + + def collection_levels(): + _media_type = 'collection' + _metadata_levels = { + 1: [ + 'ratingKey', 'title', 'titleSort', 'minYear', 'maxYear', 'addedAt', + 'contentRating', + 'summary', 'guid', 'type', 'subtype', 'childCount', + 'collectionMode', 'collectionSort', + 'children' + ], + 2: [ + 'labels.tag', + 'fields.name', 'fields.locked' + ], + 3: [ + 'art', 'thumb', 'key', + 'updatedAt' + ], + 9: self._get_all_metadata_attrs(_media_type) + } + _media_info_levels = {} + return _metadata_levels, _media_info_levels + + def playlist_levels(): + _media_type = 'playlist' + _metadata_levels = { + 1: [ + 'ratingKey', 'title', 'addedAt', + 'summary', 'guid', 'type', 'duration', 'durationHuman', + 'playlistType', 'smart', + 'items' + ], + 2: [ + ], + 3: [ + 'composite', 'key', + 'updatedAt' + ], + 9: self._get_all_metadata_attrs(_media_type) + } + _media_info_levels = {} + return _metadata_levels, _media_info_levels + + _media_types = { + 'movie': movie_levels, + 'show': show_levels, + 'season': season_levels, + 'episode': episode_levels, + 'artist': artist_levels, + 'album': album_levels, + 'track': track_levels, + 'photoalbum': photo_album_levels, + 'photo': photo_levels, + 'collection': collection_levels, + 'playlist': playlist_levels + } + + metadata_levels, media_info_levels = _media_types[media_type]() + + if reverse_map: + metadata_levels = {attr: level for level, attrs in reversed(sorted(metadata_levels.items())) + for attr in attrs} + media_info_levels = {attr: level for level, attrs in reversed(sorted(media_info_levels.items())) + for attr in attrs} + + return metadata_levels, media_info_levels + + def return_attrs_level_map(self, media_type, prefix=''): + media_attrs = self.return_attrs(media_type, flatten=True) + metadata_levels, media_info_levels = self.return_levels(media_type, reverse_map=True) + + metadata_levels_map = {} + media_info_levels_map = {} + + for attr in media_attrs: + metadata_level = metadata_levels.get( + attr, max(self.METADATA_LEVELS) if not self.is_media_info_attr(attr) else None) + media_info_level = media_info_levels.get( + attr, max(self.MEDIA_INFO_LEVELS) if self.is_media_info_attr(attr) else None) + + if metadata_level is not None: + metadata_levels_map[prefix + attr] = metadata_level + elif media_info_level is not None: + media_info_levels_map[prefix + attr] = media_info_level + + return metadata_levels_map, media_info_levels_map + + def export(self): + msg = '' + if not self.section_id and not self.user_id and not self.rating_key: + msg = "Export called but no section_id, user_id, or rating_key provided." + elif self.metadata_level not in self.METADATA_LEVELS: + msg = "Export called with invalid metadata_level '{}'.".format(self.metadata_level) + elif self.media_info_level not in self.MEDIA_INFO_LEVELS: + msg = "Export called with invalid media_info_level '{}'.".format(self.media_info_level) + elif self.file_format not in self.FILE_FORMATS: + msg = "Export called with invalid file_format '{}'.".format(self.file_format) + elif self.export_type not in self.EXPORT_TYPES: + msg = "Export called with invalid export_type '{}'.".format(self.export_type) + elif self.user_id and self.export_type != 'playlist': + msg = "Export called with invalid export_type '{}'. " \ + "Only export_type 'playlist' is allowed for user export." + + if msg: + logger.error("Tautulli Exporter :: %s", msg) + return msg + + if self.user_id: + user_data = users.Users() + user_info = user_data.get_details(user_id=self.user_id) + user_tokens = user_data.get_tokens(user_id=self.user_id) + plex_token = user_tokens['server_token'] + else: + plex_token = plexpy.CONFIG.PMS_TOKEN + + plex = Plex(plexpy.CONFIG.PMS_URL, plex_token) + + if self.rating_key: + logger.debug( + "Tautulli Exporter :: Export called with rating_key %s, " + "metadata_level %d, media_info_level %d, include_thumb %s, include_art %s", + self.rating_key, self.metadata_level, self.media_info_level, + self.include_thumb, self.include_art) + + self.obj = plex.get_item(self.rating_key) + self.media_type = self.obj.type + + if self.media_type != 'playlist': + self.section_id = self.obj.librarySectionID + + if self.media_type in ('season', 'episode', 'album', 'track'): + item_title = self.obj._defaultSyncTitle() + else: + item_title = self.obj.title + + if self.media_type == 'photo' and self.obj.TAG == 'Directory': + self.media_type = 'photoalbum' + + filename = '{} - {} [{}].{}'.format( + self.media_type.title(), item_title, self.rating_key, + helpers.timestamp_to_YMDHMS(self.timestamp)) + + elif self.user_id: + logger.debug( + "Tautulli Exporter :: Export called with user_id %s, " + "metadata_level %d, media_info_level %d, include_thumb %s, include_art %s, " + "export_type %s", + self.user_id, self.metadata_level, self.media_info_level, + self.include_thumb, self.include_art, self.export_type) + + self.obj = plex.plex + self.media_type = self.export_type + + username = user_info['username'] + + filename = 'User - {} - {} [{}].{}'.format( + username, self.export_type.capitalize(), self.user_id, + helpers.timestamp_to_YMDHMS(self.timestamp)) + + elif self.section_id: + logger.debug( + "Tautulli Exporter :: Export called with section_id %s, " + "metadata_level %d, media_info_level %d, include_thumb %s, include_art %s, " + "export_type %s", + self.section_id, self.metadata_level, self.media_info_level, + self.include_thumb, self.include_art, self.export_type) + + self.obj = plex.get_library(str(self.section_id)) + if self.export_type == 'all': + self.media_type = self.obj.type + else: + self.media_type = self.export_type + + library_title = self.obj.title + + filename = 'Library - {} - {} [{}].{}'.format( + library_title, self.export_type.capitalize(), self.section_id, + helpers.timestamp_to_YMDHMS(self.timestamp)) + + else: + msg = "Export called but no section_id, user_id, or rating_key provided." + logger.error("Tautulli Exporter :: %s", msg) + return msg + + if self.media_type not in self.MEDIA_TYPES: + msg = "Cannot export media type '{}'.".format(self.media_type) + logger.error("Tautulli Exporter :: %s", msg) + return msg + + self.include_thumb = self.include_thumb and self.MEDIA_TYPES[self.media_type] + self.include_art = self.include_art and self.MEDIA_TYPES[self.media_type] + self._process_custom_fields() + + self.filename = '{}.{}'.format(helpers.clean_filename(filename), self.file_format) + self.export_id = self.add_export() + if not self.export_id: + msg = "Failed to export '{}'.".format(self.filename) + logger.error("Tautulli Exporter :: %s", msg) + return msg + + threading.Thread(target=self._real_export).start() + + return True + + def add_export(self): + keys = {'timestamp': self.timestamp, + 'section_id': self.section_id, + 'user_id': self.user_id, + 'rating_key': self.rating_key, + 'media_type': self.media_type} + + values = {'file_format': self.file_format, + 'filename': self.filename, + 'metadata_level': self.metadata_level, + 'media_info_level': self.media_info_level, + 'include_thumb': self.include_thumb, + 'include_art': self.include_art, + 'custom_fields': self.custom_fields} + + db = database.MonitorDatabase() + try: + db.upsert(table_name='exports', key_dict=keys, value_dict=values) + return db.last_insert_id() + except Exception as e: + logger.error("Tautulli Exporter :: Unable to save export to database: %s", e) + return False + + def set_export_state(self): + if self.success: + complete = 1 + else: + complete = -1 + + keys = {'id': self.export_id} + values = {'complete': complete, + 'file_size': self.file_size, + 'include_thumb': self.include_thumb, + 'include_art': self.include_art} + + db = database.MonitorDatabase() + db.upsert(table_name='exports', key_dict=keys, value_dict=values) + + def _real_export(self): + logger.info("Tautulli Exporter :: Starting export for '%s'...", self.filename) + + filepath = get_export_filepath(self.filename) + images_folder = get_export_filepath(self.filename, images=True) + + if self.rating_key: + items = [self.obj] + elif self.user_id: + # Only playlists export allowed for users + items = self.obj.playlists() + else: + method = getattr(self.obj, self.export_type) + items = method() + + pool = ThreadPool(processes=4) + + try: + result = pool.map(self._export_obj, items) + + if self.file_format == 'csv': + csv_data = helpers.flatten_dict(result) + csv_headers = set().union(*csv_data) + with open(filepath, 'w', encoding='utf-8', newline='') as outfile: + writer = csv.DictWriter(outfile, sorted(csv_headers)) + writer.writeheader() + writer.writerows(csv_data) + + elif self.file_format == 'json': + json_data = json.dumps(result, indent=4, ensure_ascii=False, sort_keys=True) + with open(filepath, 'w', encoding='utf-8') as outfile: + outfile.write(json_data) + + elif self.file_format == 'xml': + xml_data = helpers.dict_to_xml({self.media_type: result}, root_node='export') + with open(filepath, 'w', encoding='utf-8') as outfile: + outfile.write(xml_data) + + elif self.file_format == 'm3u8': + m3u8_data = self.dict_to_m3u8(result) + with open(filepath, 'w', encoding='utf-8') as outfile: + outfile.write(m3u8_data) + + self.file_size = os.path.getsize(filepath) + + if os.path.exists(images_folder): + for f in os.listdir(images_folder): + if self.include_thumb is False and f.endswith('.thumb.jpg'): + self.include_thumb = True + if self.include_art is False and f.endswith('.art.jpg'): + self.include_art = True + + image_path = os.path.join(images_folder, f) + if os.path.isfile(image_path): + self.file_size += os.path.getsize(image_path) + + self.success = True + logger.info("Tautulli Exporter :: Successfully exported to '%s'", filepath) + + except Exception as e: + logger.exception("Tautulli Exporter :: Failed to export '%s': %s", self.filename, e) + + finally: + pool.close() + pool.join() + self.set_export_state() + + def _export_obj(self, obj): + # Reload ~plexapi.base.PlexPartialObject + if hasattr(obj, 'isPartialObject') and obj.isPartialObject(): + obj = obj.reload() + + export_attrs = self._get_export_attrs(obj.type) + return helpers.get_attrs_to_dict(obj, attrs=export_attrs) + + def _process_custom_fields(self): + if self.custom_fields: + logger.debug("Tautulli Exporter :: Processing custom fields: %s", self.custom_fields) + + for field in self.custom_fields.split(','): + field = field.strip() + if not field: + continue + + media_type = self.PLURAL_MEDIA_TYPES[self.media_type] + for key in self.PLURAL_MEDIA_TYPES.values(): + if field.startswith(key + '.'): + media_type, field = field.split('.', maxsplit=1) + + if media_type in self._custom_fields: + self._custom_fields[media_type].add(field) + else: + self._custom_fields[media_type] = {field} + + def _get_all_metadata_attrs(self, media_type): + exclude_attrs = ('locations', 'media', 'artFile', 'thumbFile') + all_attrs = self.return_attrs(media_type) + return [attr for attr in all_attrs if attr not in exclude_attrs] + + def _get_export_attrs(self, media_type): + media_attrs = self.return_attrs(media_type) + metadata_level_attrs, media_info_level_attrs = self.return_levels(media_type) + + export_attrs_list = [] + export_attrs_set = set() + + for level, attrs in metadata_level_attrs.items(): + if level <= self.metadata_level: + export_attrs_set.update(attrs) + + for level, attrs in media_info_level_attrs.items(): + if level <= self.media_info_level: + export_attrs_set.update(attrs) + + if self.include_thumb: + if 'thumbFile' in media_attrs: + export_attrs_set.add('thumbFile') + if self.include_art: + if 'artFile' in media_attrs: + export_attrs_set.add('artFile') + + plural_media_type = self.PLURAL_MEDIA_TYPES.get(media_type) + if plural_media_type in self._custom_fields: + export_attrs_set.update(self._custom_fields[plural_media_type]) + if self.media_type == 'collection' and 'children' in self._custom_fields: + export_attrs_set.update(self._custom_fields['children']) + elif self.media_type == 'playlist' and 'items' in self._custom_fields: + export_attrs_set.update(self._custom_fields['items']) + + for attr in export_attrs_set: + try: + value = helpers.get_dict_value_by_path(media_attrs, attr) + except (KeyError, TypeError): + logger.warn("Tautulli Exporter :: Unknown export attribute '%s', skipping...", attr) + continue + + export_attrs_list.append(value) + + return reduce(helpers.dict_merge, export_attrs_list, {}) + + def get_any_hdr(self, item, media_type): + root = self.return_attrs(media_type)['media'] + attrs = helpers.get_dict_value_by_path(root, 'parts.videoStreams.hdr') + media = helpers.get_attrs_to_dict(item, attrs) + return any(vs.get('hdr') for p in media.get('parts', []) for vs in p.get('videoStreams', [])) + + def get_image(self, item, image): + media_type = item.type + rating_key = item.ratingKey + + if media_type in ('season', 'episode', 'album', 'track'): + item_title = item._defaultSyncTitle() + else: + item_title = item.title + + folder = get_export_filepath(self.filename, images=True) + filename = helpers.clean_filename('{} [{}].{}.jpg'.format(item_title, rating_key, image)) + filepath = os.path.join(folder, filename) + + if not os.path.exists(folder): + os.makedirs(folder) + + image_url = None + if image == 'art': + image_url = item.artUrl + elif image == 'thumb': + image_url = item.thumbUrl + + if not image_url: + return + + r = requests.get(image_url, stream=True) + if r.status_code == 200: + with open(filepath, 'wb') as outfile: + for chunk in r: + outfile.write(chunk) + + return os.path.join(os.path.basename(folder), filename) + + @staticmethod + def is_media_info_attr(attr): + return attr.startswith('media.') or attr == 'locations' + + def dict_to_m3u8(self, data): + items = self._get_m3u8_items(data) + + m3u8 = '#EXTM3U\n' + m3u8 += '# Playlist: {}\n\n'.format(self.filename) + m3u8_item_template = '# ratingKey: {ratingKey}\n#EXTINF:{duration},{title}\n{location}\n' + m3u8_items = [] + + for item in items: + m3u8_items.append(m3u8_item_template.format(**item)) + + m3u8 = m3u8 + '\n'.join(m3u8_items) + + return m3u8 + + def _get_m3u8_items(self, data): + items = [] + + for d in data: + if 'locations' in d: + location = { + 'ratingKey': d['ratingKey'], + 'duration': d['duration'], + 'title': d['title'], + 'location': d['locations'][0] + } + items.append(location) + + child_media_type = self.CHILD_MEDIA_TYPES[d['type']] + if child_media_type: + child_locations = self._get_m3u8_items(d[self.PLURAL_MEDIA_TYPES[child_media_type]]) + items.extend(child_locations) + + return items + + +def get_export(export_id): + db = database.MonitorDatabase() + result = db.select_single('SELECT filename, file_format, include_thumb, include_art, complete ' + 'FROM exports WHERE id = ?', + [export_id]) + + if result: + result['exists'] = check_export_exists(result['filename']) + + return result + + +def delete_export(export_id): + db = database.MonitorDatabase() + if str(export_id).isdigit(): + export_data = get_export(export_id=export_id) + + logger.info("Tautulli Exporter :: Deleting export_id %s from the database.", export_id) + result = db.action('DELETE FROM exports WHERE id = ?', args=[export_id]) + + if export_data and export_data['exists']: + filepath = get_export_filepath(export_data['filename']) + logger.info("Tautulli Exporter :: Deleting exported file from '%s'.", filepath) + try: + os.remove(filepath) + if export_data['include_thumb'] or export_data['include_art']: + images_folder = get_export_filepath(export_data['filename'], images=True) + if os.path.exists(images_folder): + shutil.rmtree(images_folder) + except OSError as e: + logger.error("Tautulli Exporter :: Failed to delete exported file '%s': %s", filepath, e) + return True + else: + return False + + +def delete_all_exports(): + db = database.MonitorDatabase() + result = db.select('SELECT filename, include_thumb, include_art FROM exports') + + logger.info("Tautulli Exporter :: Deleting all exports from the database.") + + deleted_files = True + for row in result: + if check_export_exists(row['filename']): + filepath = get_export_filepath(row['filename']) + try: + os.remove(filepath) + if row['include_thumb'] or row['include_art']: + images_folder = get_export_filepath(row['filename'], images=True) + if os.path.exists(images_folder): + shutil.rmtree(images_folder) + except OSError as e: + logger.error("Tautulli Exporter :: Failed to delete exported file '%s': %s", filepath, e) + deleted_files = False + break + + if deleted_files: + database.delete_exports() + return True + + +def cancel_exports(): + db = database.MonitorDatabase() + db.action('UPDATE exports SET complete = -1 WHERE complete = 0') + + +def get_export_datatable(section_id=None, user_id=None, rating_key=None, kwargs=None): + default_return = {'recordsFiltered': 0, + 'recordsTotal': 0, + 'draw': 0, + 'data': 'null', + 'error': 'Unable to execute database query.'} + + data_tables = datatables.DataTables() + + custom_where = [] + if section_id: + custom_where.append(['exports.section_id', section_id]) + if user_id: + custom_where.append(['exports.user_id', user_id]) + if rating_key: + custom_where.append(['exports.rating_key', rating_key]) + + columns = ['exports.id AS export_id', + 'exports.timestamp', + 'exports.section_id', + 'exports.user_id', + 'exports.rating_key', + 'exports.media_type', + 'exports.filename', + 'exports.file_format', + 'exports.metadata_level', + 'exports.media_info_level', + 'exports.include_thumb', + 'exports.include_art', + 'exports.custom_fields', + 'exports.file_size', + 'exports.complete' + ] + try: + query = data_tables.ssp_query(table_name='exports', + columns=columns, + custom_where=custom_where, + group_by=[], + join_types=[], + join_tables=[], + join_evals=[], + kwargs=kwargs) + except Exception as e: + logger.warn("Tautulli Exporter :: Unable to execute database query for get_export_datatable: %s.", e) + return default_return + + result = query['result'] + + rows = [] + for item in result: + media_type_title = item['media_type'].title() + exists = helpers.cast_to_int(check_export_exists(item['filename'])) + + row = {'export_id': item['export_id'], + 'timestamp': item['timestamp'], + 'section_id': item['section_id'], + 'user_id': item['user_id'], + 'rating_key': item['rating_key'], + 'media_type': item['media_type'], + 'media_type_title': media_type_title, + 'filename': item['filename'], + 'file_format': item['file_format'], + 'metadata_level': item['metadata_level'], + 'media_info_level': item['media_info_level'], + 'include_thumb': item['include_thumb'], + 'include_art': item['include_art'], + 'custom_fields': item['custom_fields'], + 'file_size': item['file_size'], + 'complete': item['complete'], + 'exists': exists + } + + rows.append(row) + + result = {'recordsFiltered': query['filteredCount'], + 'recordsTotal': query['totalCount'], + 'data': rows, + 'draw': query['draw'] + } + + return result + + +def get_export_filepath(filename, images=False): + if images: + images_folder = '{}.images'.format(os.path.splitext(filename)[0]) + return os.path.join(plexpy.CONFIG.EXPORT_DIR, images_folder) + return os.path.join(plexpy.CONFIG.EXPORT_DIR, filename) + + +def check_export_exists(filename): + return os.path.isfile(get_export_filepath(filename)) + + +def get_custom_fields(media_type, sub_media_type=None): + custom_fields = { + 'metadata_fields': [], + 'media_info_fields': [] + } + + collection_sub_media_types = {'movie', 'show', 'artist', 'album', 'photoalbum'} + playlist_sub_media_types = {'video', 'audio', 'photo'} + sub_media_type = {s.strip().lower() for s in sub_media_type.split(',')} + + export = Export() + + if media_type not in export.MEDIA_TYPES: + return custom_fields + elif media_type == 'collection' and not sub_media_type.issubset(collection_sub_media_types): + return custom_fields + elif media_type == 'playlist' and not sub_media_type.issubset(playlist_sub_media_types): + return custom_fields + + sub_media_types = list(sub_media_type.difference(playlist_sub_media_types)) + if media_type == 'playlist' and 'video' in sub_media_type: + sub_media_types += ['movie', 'episode'] + elif media_type == 'playlist' and 'audio' in sub_media_type: + sub_media_types += ['track'] + elif media_type == 'playlist' and 'photo' in sub_media_type: + sub_media_types += ['photo'] + + metadata_levels_map, media_info_levels_map = export.return_attrs_level_map(media_type) + + for sub_media_type in sub_media_types: + prefix = '' + child_media_type = export.CHILD_MEDIA_TYPES[media_type] + + while child_media_type: + if child_media_type in ('children', 'item'): + fields_child_media_type = sub_media_type + else: + fields_child_media_type = child_media_type + + prefix = prefix + export.PLURAL_MEDIA_TYPES[child_media_type] + '.' + + child_metadata_levels_map, child_media_info_levels_map = export.return_attrs_level_map( + fields_child_media_type, prefix=prefix) + + metadata_levels_map.update(child_metadata_levels_map) + media_info_levels_map.update(child_media_info_levels_map) + + child_media_type = export.CHILD_MEDIA_TYPES.get(fields_child_media_type) + + custom_fields['metadata_fields'] = [{'field': attr, 'level': level} + for attr, level in sorted(metadata_levels_map.items()) if level] + custom_fields['media_info_fields'] = [{'field': attr, 'level': level} + for attr, level in sorted(media_info_levels_map.items()) if level] + + return custom_fields diff --git a/plexpy/helpers.py b/plexpy/helpers.py index fee9ae53..2732b546 100644 --- a/plexpy/helpers.py +++ b/plexpy/helpers.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- +# -*- coding: utf-8 -*- # 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.utils import cloudinary_url import datetime -from functools import wraps +from functools import reduce, wraps import hashlib import imghdr from future.moves.itertools import islice, zip_longest @@ -38,6 +38,7 @@ import ipwhois.utils from IPy import IP import json import math +import operator import os import re import shlex @@ -242,30 +243,44 @@ def iso_to_datetime(iso): 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: - d, h = divmod(s, 86400) - h, m = divmod(h, 3600) - m, s = divmod(m, 60) +def human_duration(ms, sig='dhms', units='ms'): + factors = {'d': 86400000, + 'h': 3600000, + '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 = [] if sig >= 'd' and d > 0: 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: 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: 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: - hd_list.append(str(s) + ' secs') + hd_list.append(str(s) + ' sec' + ('s' if s > 1 else '')) hd = ' '.join(hd_list) else: @@ -382,6 +397,13 @@ def cleanTitle(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): """ 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 +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): """ 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) -def humanFileSize(bytes, si=True): +def human_file_size(bytes, si=True): if str(bytes).isdigit(): bytes = cast_to_float(bytes) else: @@ -919,7 +999,7 @@ def humanFileSize(bytes, si=True): bytes /= thresh 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): @@ -1153,6 +1233,205 @@ def bool_true(value, return_none=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): endpoints = { 'pms_image_proxy': pms_image_proxy, diff --git a/plexpy/libraries.py b/plexpy/libraries.py index e73c6724..a9294114 100644 --- a/plexpy/libraries.py +++ b/plexpy/libraries.py @@ -33,6 +33,7 @@ if plexpy.PYTHON2: import plextv import pmsconnect import session + from plex import Plex else: from plexpy import common from plexpy import database @@ -42,6 +43,7 @@ else: from plexpy import plextv from plexpy import pmsconnect from plexpy import session + from plexpy.plex import Plex def refresh_libraries(): @@ -142,6 +144,163 @@ def has_library_type(section_type): 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): def __init__(self): diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py index 28078275..2d8ec6a1 100644 --- a/plexpy/notification_handler.py +++ b/plexpy/notification_handler.py @@ -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'], 'file': 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'], 'section_id': notify_params['section_id'], 'rating_key': notify_params['rating_key'], diff --git a/plexpy/notifiers.py b/plexpy/notifiers.py index 2f25ff82..0b9dd939 100644 --- a/plexpy/notifiers.py +++ b/plexpy/notifiers.py @@ -3028,8 +3028,7 @@ class SCRIPTS(Notifier): if 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'): custom_env['PYTHONPATH'] = os.pathsep.join([p for p in sys.path if p]) diff --git a/plexpy/plex.py b/plexpy/plex.py new file mode 100644 index 00000000..b46f0722 --- /dev/null +++ b/plexpy/plex.py @@ -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 . + +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) diff --git a/plexpy/pmsconnect.py b/plexpy/pmsconnect.py index 89216c50..cd51c3d8 100644 --- a/plexpy/pmsconnect.py +++ b/plexpy/pmsconnect.py @@ -24,6 +24,7 @@ import json import os import time from future.moves.urllib.parse import quote, quote_plus, urlencode +from xml.dom.minidom import Node import plexpy if plexpy.PYTHON2: @@ -31,6 +32,7 @@ if plexpy.PYTHON2: import common import helpers import http_handler + import libraries import logger import plextv import session @@ -40,6 +42,7 @@ else: from plexpy import common from plexpy import helpers from plexpy import http_handler + from plexpy import libraries from plexpy import logger from plexpy import plextv from plexpy import session @@ -173,6 +176,22 @@ class PmsConnect(object): 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=''): """ Return list of recently added items. @@ -594,7 +613,7 @@ class PmsConnect(object): 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): """ Return processed and validated metadata list for requested item. @@ -654,6 +673,8 @@ class PmsConnect(object): metadata_main_list = a.getElementsByTagName('Track') elif a.getElementsByTagName('Photo'): metadata_main_list = a.getElementsByTagName('Photo') + elif a.getElementsByTagName('Playlist'): + metadata_main_list = a.getElementsByTagName('Playlist') else: logger.debug("Tautulli Pmsconnect :: Metadata failed") return {} @@ -669,9 +690,13 @@ class PmsConnect(object): if metadata_main.nodeName == 'Directory' and metadata_type == 'photo': 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') + if not library_name and section_id: + library_data = libraries.Libraries().get_details(section_id) + library_name = library_data['section_name'] + directors = [] writers = [] actors = [] @@ -1247,7 +1272,27 @@ class PmsConnect(object): 'collections': collections, 'guids': guids, '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')), + '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') } @@ -2242,13 +2287,15 @@ class PmsConnect(object): logger.warn("Tautulli Pmsconnect :: Failed to terminate session: %s." % 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. 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') else: children_data = self.get_metadata_children(rating_key, output_format='xml') @@ -2272,12 +2319,9 @@ class PmsConnect(object): result_data = [] - if a.getElementsByTagName('Directory'): - result_data = a.getElementsByTagName('Directory') - if a.getElementsByTagName('Video'): - result_data = a.getElementsByTagName('Video') - if a.getElementsByTagName('Track'): - result_data = a.getElementsByTagName('Track') + for x in a.childNodes: + if x.nodeType == Node.ELEMENT_NODE and x.tagName in ('Directory', 'Video', 'Track', 'Photo'): + result_data.append(x) if result_data: for m in result_data: @@ -2307,7 +2351,11 @@ class PmsConnect(object): for label in m.getElementsByTagName('Label'): 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'), 'library_name': helpers.get_xml_attr(m, 'librarySectionTitle'), 'rating_key': helpers.get_xml_attr(m, 'ratingKey'), diff --git a/plexpy/session.py b/plexpy/session.py index c4346ede..b3be5170 100644 --- a/plexpy/session.py +++ b/plexpy/session.py @@ -57,6 +57,22 @@ def get_session_user_id(): _session = get_session_info() 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(): """ Returns a tuple of section_id for the current logged in session diff --git a/plexpy/users.py b/plexpy/users.py index c338f26b..ebb5cfa5 100644 --- a/plexpy/users.py +++ b/plexpy/users.py @@ -789,6 +789,12 @@ class Users(object): return session.friendly_name_to_username(result) def get_tokens(self, user_id=None): + tokens = { + 'allow_guest': 0, + 'user_token': '', + 'server_token': '' + } + if user_id: try: monitor_db = database.MonitorDatabase() @@ -802,11 +808,11 @@ class Users(object): } return tokens else: - return None + return tokens except: - return None + return tokens - return None + return tokens def get_filters(self, user_id=None): if not user_id: diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 22885cd7..cbb78377 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -19,8 +19,9 @@ from __future__ import unicode_literals from future.builtins import next from future.builtins import object from future.builtins import str +from backports import csv -from io import open +from io import open, BytesIO import base64 import json import linecache @@ -28,10 +29,11 @@ import os import shutil import sys import threading +import zipfile from future.moves.urllib.parse import urlencode 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 hashing_passwords import make_hash @@ -48,6 +50,7 @@ if plexpy.PYTHON2: import config import database import datafactory + import exporter import graphs import helpers import http_handler @@ -81,6 +84,7 @@ else: from plexpy import config from plexpy import database from plexpy import datafactory + from plexpy import exporter from plexpy import graphs from plexpy import helpers from plexpy import http_handler @@ -839,6 +843,82 @@ class WebInterface(object): 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.tools.json_out() @requireAuth(member_of("admin")) @@ -2997,6 +3077,7 @@ class WebInterface(object): "backup_dir": plexpy.CONFIG.BACKUP_DIR, "backup_interval": plexpy.CONFIG.BACKUP_INTERVAL, "cache_dir": plexpy.CONFIG.CACHE_DIR, + "export_dir": plexpy.CONFIG.EXPORT_DIR, "log_dir": plexpy.CONFIG.LOG_DIR, "log_blacklist": checked(plexpy.CONFIG.LOG_BLACKLIST), "check_github": checked(plexpy.CONFIG.CHECK_GITHUB), @@ -4301,7 +4382,7 @@ class WebInterface(object): @cherrypy.expose @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(): raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) @@ -4312,10 +4393,16 @@ class WebInterface(object): "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 if rating_key: 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 not metadata and source == 'history': @@ -4334,7 +4421,7 @@ class WebInterface(object): raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) return serve_template(templatename="info.html", metadata=metadata, title="Info", - config=config, source=source) + config=config, source=source, user_info=user_info) else: if get_session_user_id(): raise cherrypy.HTTPRedirect(plexpy.HTTP_ROOT) @@ -4343,13 +4430,14 @@ class WebInterface(object): @cherrypy.expose @requireAuth() - def get_item_children(self, rating_key='', **kwargs): + def get_item_children(self, rating_key='', media_type=None, **kwargs): 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: - 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: logger.warn("Unable to retrieve data for get_item_children.") 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) if img.startswith('/library/metadata'): + parts = 6 if 'composite' in img else 5 img_split = img.split('/') - img = '/'.join(img_split[:5]) + img = '/'.join(img_split[:parts]) img_rating_key = img_split[3] if rating_key != img_rating_key: rating_key = img_rating_key @@ -6426,3 +6515,307 @@ class WebInterface(object): status['message'] = 'Database not ok' 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 = '' + \ + ''.join( + '' for row in reader) + \ + '
' + \ + ''.join(reader.fieldnames) + \ + '
' + ''.join(row.values()) + '
' + style = '' + return '{style}
{table}
'.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.'}