diff --git a/data/interfaces/default/css/tautulli.css b/data/interfaces/default/css/tautulli.css index ceb9b91c..aae10ac2 100644 --- a/data/interfaces/default/css/tautulli.css +++ b/data/interfaces/default/css/tautulli.css @@ -2941,6 +2941,12 @@ a .home-platforms-list-cover-face:hover max-width: 1750px; display: flow-root; } +.table-card-header.spaced { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + align-content: center; +} .table-card-back td { font-size: 12px; } @@ -2952,6 +2958,18 @@ a .home-platforms-list-cover-face:hover font-weight: bold; line-height: 34px; } +.info-bar { + display: inline; +} +.info-element { + display: inline-block; + border-radius: 1rem; + border: 0.2rem solid #242424; + padding: 0.7rem; + background-color: #3B3B3B; + font-style: italic; + color: #676767; +} .button-bar { float: right; } diff --git a/data/interfaces/default/js/tables/libraries.js b/data/interfaces/default/js/tables/libraries.js index b3e702a0..ee8aa488 100644 --- a/data/interfaces/default/js/tables/libraries.js +++ b/data/interfaces/default/js/tables/libraries.js @@ -202,6 +202,30 @@ libraries_list_table_options = { "searchable": false, "width": "10%", "className": "no-wrap" + }, + { + "targets": [11], + "data": "total_storage", + "createdCell": function (td, cellData, rowData, row, col) { + if (cellData !== null && cellData !== '') { + $(td).html(humanFileSize(cellData)); + } + }, + "searchable": false, + "width": "10%", + "className": "no-wrap" + }, + { + "targets": [12], + "data": "total_duration", + "createdCell": function (td, cellData, rowData, row, col) { + if (cellData !== null && cellData !== '') { + $(td).html(humanDuration(cellData)); + } + }, + "searchable": false, + "width": "10%", + "className": "no-wrap" } ], diff --git a/data/interfaces/default/js/tables/media_info_table.js b/data/interfaces/default/js/tables/media_info_table.js index f4d80d53..c27eadb3 100644 --- a/data/interfaces/default/js/tables/media_info_table.js +++ b/data/interfaces/default/js/tables/media_info_table.js @@ -224,6 +224,22 @@ media_info_table_options = { }, { "targets": [10], + "data": "duration", + "createdCell": function (td, cellData, rowData, row, col) { + if (cellData !== null && cellData !== '' && cellData !== 0) { + $(td).html(humanDuration(cellData)); + } else { + if (rowData['section_type'] != 'photo' && get_file_sizes != null) { + get_file_sizes = true; + } + } + }, + "width": "7%", + "className": "no-wrap", + "searchable": false + }, + { + "targets": [11], "data": "last_played", "createdCell": function (td, cellData, rowData, row, col) { if (cellData !== null && cellData !== '') { @@ -236,7 +252,7 @@ media_info_table_options = { "searchable": false }, { - "targets": [11], + "targets": [12], "data": "play_count", "createdCell": function (td, cellData, rowData, row, col) { if (cellData !== null && cellData !== '') { @@ -457,6 +473,7 @@ function childTableFormatMedia(rowData) { 'Audio Codec' + 'Audio Channels' + 'File Size' + + 'Duration' + 'Last Played' + 'Total Plays' + '' + diff --git a/data/interfaces/default/libraries.html b/data/interfaces/default/libraries.html index 70468ff3..5ad1dc06 100644 --- a/data/interfaces/default/libraries.html +++ b/data/interfaces/default/libraries.html @@ -41,6 +41,8 @@ Last Played Total Plays Total Played Duration + Total Storage + Total Runtime diff --git a/data/interfaces/default/library.html b/data/interfaces/default/library.html index ba61153d..83a1998a 100644 --- a/data/interfaces/default/library.html +++ b/data/interfaces/default/library.html @@ -271,7 +271,7 @@ DOCUMENTATION :: END You may leave this page and check back later. % endif -
+
Media Info for @@ -279,6 +279,14 @@ DOCUMENTATION :: END
+
+
+ Total File Size: +
+
+ Total Media Runtime: +
+
% if _session['user_group'] == 'admin':
@@ -310,6 +318,7 @@ DOCUMENTATION :: END Audio Codec Audio Channels File Size + Duration Last Played Total Plays @@ -779,6 +788,22 @@ DOCUMENTATION :: END clearSearchButton('media_info_table-SID-${data["section_id"]}', media_info_table); } + $(document).ready(function () { + loadLibraryMediaStats(); + }); + + function loadLibraryMediaStats(refresh) { + // Populate media stats + $.ajax({ + url: 'get_library_media_stats', + data: { section_id: section_id, refresh: refresh }, + complete: function(xhr, status) { + $("#info-element-total-runtime").html(humanDuration(xhr.responseJSON.total_duration)); + $("#info-element-total-storage").html(humanFileSize(xhr.responseJSON.total_storage)); + } + }); + } + $('#nav-tabs-mediainfo').on('shown.bs.tab', function() { if (typeof(media_info_table) === 'undefined') { loadMediaInfoTable(); @@ -790,6 +815,7 @@ DOCUMENTATION :: END refresh_table = true; refresh_child_tables = true; media_info_table.draw(); + loadLibraryMediaStats(true); refresh_table = false; }); diff --git a/plexpy/libraries.py b/plexpy/libraries.py index 454f6128..bca471f9 100644 --- a/plexpy/libraries.py +++ b/plexpy/libraries.py @@ -75,6 +75,8 @@ def refresh_libraries(): if result == 'insert': new_keys.append(section['section_id']) + get_library_media_stats(section_id=section['section_id'], refresh=True) + add_live_tv_library(refresh=True) query = "UPDATE library_sections SET is_active = 0 WHERE server_id != ? OR " \ @@ -125,6 +127,44 @@ def has_library_type(section_type): result = monitor_db.select_single(query=query, args=args) return bool(result) +def get_library_media_stats(section_id=None, refresh=False): + plex = Plex(token=session.get_session_user_token()) + libraries = Libraries() + + default_return = { + 'total_size': 0, + 'total_storage': 0, + 'total_duration': 0 + } + + if section_id and not str(section_id).isdigit(): + logger.warn("Tautulli Libraries :: Library media stats requested but invalid section_id provided.") + return default_return + + if not session.allow_session_library(section_id): + logger.warn("Tautulli Libraries :: Library media stats requested but library is not allowed for this session.") + return default_return + + # Import media info cache from json file + _, cached_library_media_stats, _ = libraries._load_data_from_cache(section_id=section_id, path='media_stats') + + _live_data = not cached_library_media_stats or refresh + if _live_data: + library = plex.get_library(section_id) + + if library is None: + logger.warn("Tautulli Libraries :: Library media stats requested but no library was found section_id %s.", section_id) + return default_return + + library_media_stats = { + 'total_size': library.totalSize if _live_data else cached_library_media_stats.get('total_size', 0), + 'total_storage': library.totalStorage if _live_data else cached_library_media_stats.get('total_storage', 0), + 'total_duration': library.totalDuration if _live_data else cached_library_media_stats.get('total_duration', 0) + } + + libraries._save_data_to_cache(section_id=section_id, rows=library_media_stats, path='media_stats') + + return library_media_stats def get_collections(section_id=None): plex = Plex(token=session.get_session_user_token()) @@ -391,18 +431,23 @@ class Libraries(object): else: library_art = item['library_art'] + library_media_stats = get_library_media_stats(item['section_id']) + row = {'row_id': item['row_id'], 'server_id': item['server_id'], 'section_id': item['section_id'], 'section_name': item['section_name'], 'section_type': item['section_type'], 'count': item['count'], + 'total_size': library_media_stats['total_size'], 'parent_count': item['parent_count'], 'child_count': item['child_count'], 'library_thumb': library_thumb, 'library_art': library_art, 'plays': item['plays'], + 'total_storage': library_media_stats['total_storage'], 'duration': item['duration'], + 'total_duration': library_media_stats['total_duration'], 'last_accessed': item['last_accessed'], 'history_row_id': item['history_row_id'], 'last_played': item['last_played'], @@ -441,6 +486,7 @@ class Libraries(object): 'data': [], 'filtered_file_size': 0, 'total_file_size': 0, + 'total_media_duration': 0, 'last_refreshed': None} if not session.allow_session_library(section_id): @@ -497,10 +543,13 @@ class Libraries(object): 'play_count': item['play_count']} # Import media info cache from json file - cache_time, rows, library_count = self._load_media_info_cache(section_id=section_id, rating_key=rating_key) + cache_time, rows, library_count = self._load_data_from_cache(section_id=section_id, rating_key=rating_key, path='media_info') + + # Check if duration is also included in cache else refresh cache to prevent update issues + refresh = refresh if None not in {d.get('duration') for d in rows} else True # If no cache was imported, get all library children items - cached_items = {d['rating_key']: d['file_size'] for d in rows} if not refresh else {} + cached_items = {d['rating_key']: [d['file_size'], d['duration']] for d in rows} if not refresh else {} if refresh or not rows: pms_connect = pmsconnect.PmsConnect() @@ -525,8 +574,10 @@ class Libraries(object): for item in children_list: ## TODO: Check list of media info items, currently only grabs first item - cached_file_size = cached_items.get(item['rating_key'], None) - file_size = cached_file_size if cached_file_size else item.get('file_size', '') + cached_item_data = cached_items.get(item['rating_key'], None) + + file_size = cached_item_data['file_size'] if cached_item_data else item.get('file_size', '') + duration = cached_item_data['duration'] if cached_item_data else item['duration'] row = {'section_id': library_details['section_id'], 'section_type': library_details['section_type'], @@ -548,7 +599,8 @@ class Libraries(object): 'video_framerate': item.get('video_framerate', ''), 'audio_codec': item.get('audio_codec', ''), 'audio_channels': item.get('audio_channels', ''), - 'file_size': file_size + 'file_size': file_size, + 'duration': duration } new_rows.append(row) @@ -557,7 +609,7 @@ class Libraries(object): return default_return # Cache the media info to a json file - self._save_media_info_cache(section_id=section_id, rating_key=rating_key, rows=rows) + self._save_data_to_cache(section_id=section_id, rating_key=rating_key, rows=rows, path='media_info') # Update the last_played and play_count for item in rows: @@ -606,6 +658,7 @@ class Libraries(object): results = sorted(results, key=lambda k: k[sort_key].lower(), reverse=reverse) total_file_size = sum([helpers.cast_to_int(d['file_size']) for d in results]) + total_media_duration = sum([helpers.cast_to_int(d['duration']) for d in results]) # Paginate results results = results[json_data['start']:(json_data['start'] + json_data['length'])] @@ -619,6 +672,7 @@ class Libraries(object): 'draw': int(json_data['draw']), 'filtered_file_size': filtered_file_size, 'total_file_size': total_file_size, + 'total_media_duration': total_media_duration, 'last_refreshed': cache_time } @@ -644,19 +698,20 @@ class Libraries(object): return False # Import media info cache from json file - _, rows, _ = self._load_media_info_cache(section_id=section_id, rating_key=rating_key) + _, rows, _ = self._load_data_from_cache(section_id=section_id, rating_key=rating_key, path='media_info') # Get the total file size for each item if rating_key: logger.debug("Tautulli Libraries :: Getting file sizes for rating_key %s." % rating_key) elif section_id: - logger.debug("Tautulli Libraries :: Fetting file sizes for section_id %s." % section_id) + logger.debug("Tautulli Libraries :: Fetching file sizes for section_id %s." % section_id) pms_connect = pmsconnect.PmsConnect() for item in rows: - if item['rating_key'] and not item['file_size']: + if item['rating_key'] and (not item['file_size'] or not item['duration']): file_size = 0 + duration = 0 metadata = pms_connect.get_metadata_children_details(rating_key=item['rating_key'], get_children=True, @@ -673,11 +728,13 @@ class Libraries(object): media_info['parts'][0]) file_size += helpers.cast_to_int(media_part_info.get('file_size', 0)) + duration += helpers.cast_to_int(child_metadata.get('duration', 0)) item['file_size'] = file_size + item['duration'] = duration # Cache the media info to a json file - self._save_media_info_cache(section_id=section_id, rating_key=rating_key, rows=rows) + self._save_data_to_cache(section_id=section_id, rating_key=rating_key, rows=rows, path='media_info') if rating_key: logger.debug("Tautulli Libraries :: File sizes updated for rating_key %s." % rating_key) @@ -686,67 +743,51 @@ class Libraries(object): return True - def _load_media_info_cache(self, section_id=None, rating_key=None): + def _load_data_from_cache(self, section_id=None, rating_key=None, path=None): cache_time = None rows = [] library_count = 0 - # Import media info cache from json file - if rating_key: - try: - inFilePath = os.path.join(plexpy.CONFIG.CACHE_DIR,'media_info_%s-%s.json' % (section_id, rating_key)) - with open(inFilePath, 'r') as inFile: - data = json.load(inFile) - if isinstance(data, dict): - cache_time = data['last_refreshed'] - rows = data['rows'] - else: - rows = data - library_count = len(rows) - logger.debug("Tautulli Libraries :: Loaded media info from cache for rating_key %s (%s items)." % (rating_key, library_count)) - except IOError as e: - logger.debug("Tautulli Libraries :: No media info cache for rating_key %s." % rating_key) + section_id = str(section_id) if section_id else section_id + rating_key = str(rating_key) if rating_key else rating_key - elif section_id: - try: - inFilePath = os.path.join(plexpy.CONFIG.CACHE_DIR,'media_info_%s.json' % section_id) - with open(inFilePath, 'r') as inFile: - data = json.load(inFile) - if isinstance(data, dict): - cache_time = data['last_refreshed'] - rows = data['rows'] - else: - rows = data - library_count = len(rows) - logger.debug("Tautulli Libraries :: Loaded media info from cache for section_id %s (%s items)." % (section_id, library_count)) - except IOError as e: - logger.debug("Tautulli Libraries :: No media info cache for section_id %s." % section_id) + # Import data cache from json file + try: + inFilePath = os.path.join(plexpy.CONFIG.CACHE_DIR, (path + '_%s%s' % (section_id, ('-' + rating_key) if rating_key else ''))) + with open(inFilePath, 'r') as inFile: + data = json.load(inFile) + if isinstance(data, dict): + cache_time = data['last_refreshed'] + rows = data['rows'] + else: + rows = data + library_count = len(rows) + logger.debug("Tautulli Libraries :: Loaded %s from cache for section_id %s%s (%s items)." % + (path, section_id, (' and rating key ' + rating_key) if rating_key else '', library_count)) + except IOError as e: + logger.debug("Tautulli Libraries :: No media info cache for section_id %s%s." % + (section_id, (' and rating key ' + rating_key) if rating_key else '')) return cache_time, rows, library_count - def _save_media_info_cache(self, section_id=None, rating_key=None, rows=None): + def _save_data_to_cache(self, section_id, rating_key=None, rows=None, path=None): cache_time = helpers.timestamp() if rows is None: rows = [] - - if rating_key: - try: - outFilePath = os.path.join(plexpy.CONFIG.CACHE_DIR,'media_info_%s-%s.json' % (section_id, rating_key)) - with open(outFilePath, 'w') as outFile: - json.dump({'last_refreshed': cache_time, 'rows': rows}, outFile) - logger.debug("Tautulli Libraries :: Saved media info cache for rating_key %s." % rating_key) - except IOError as e: - logger.debug("Tautulli Libraries :: Unable to create cache file for rating_key %s." % rating_key) - elif section_id: - try: - outFilePath = os.path.join(plexpy.CONFIG.CACHE_DIR,'media_info_%s.json' % section_id) - with open(outFilePath, 'w') as outFile: - json.dump({'last_refreshed': cache_time, 'rows': rows}, outFile) - logger.debug("Tautulli Libraries :: Saved media info cache for section_id %s." % section_id) - except IOError as e: - logger.debug("Tautulli Libraries :: Unable to create cache file for section_id %s." % section_id) + section_id = str(section_id) if section_id else section_id + rating_key = str(rating_key) if rating_key else rating_key + + try: + outFilePath = os.path.join(plexpy.CONFIG.CACHE_DIR, (path + '_%s%s' % (section_id, ('-' + rating_key) if rating_key else ''))) + with open(outFilePath, 'w') as outFile: + json.dump({'last_refreshed': cache_time, 'rows': rows}, outFile) + logger.debug("Tautulli Libraries :: Saved %s cache for section_id %s%s." % + (path, section_id, (' and rating key ' + rating_key) if rating_key else '')) + except IOError as e: + logger.debug("Tautulli Libraries :: Unable to create cache file for section_id %s%s with error %s." % + (section_id, (' and rating key ' + rating_key) if rating_key else '', e)) def set_config(self, section_id=None, custom_thumb='', custom_art='', do_notify=1, keep_history=1, do_notify_created=1): diff --git a/plexpy/plex.py b/plexpy/plex.py index 05a1bbb8..6240b6ad 100644 --- a/plexpy/plex.py +++ b/plexpy/plex.py @@ -50,7 +50,14 @@ class Plex(object): self.PlexServer = PlexObject(url, token) def get_library(self, section_id): - return self.PlexServer.library.sectionByID(int(section_id)) + from plexapi.exceptions import NotFound + + try: + library = self.PlexServer.library.sectionByID(int(section_id)) + except NotFound: + library = None + + return library def get_library_items(self, section_id): return self.get_library(section_id).all() diff --git a/plexpy/pmsconnect.py b/plexpy/pmsconnect.py index ac23ce82..84170eb0 100644 --- a/plexpy/pmsconnect.py +++ b/plexpy/pmsconnect.py @@ -2912,7 +2912,8 @@ class PmsConnect(object): 'thumb': helpers.get_xml_attr(item, 'thumb'), 'parent_thumb': helpers.get_xml_attr(item, 'thumb'), 'grandparent_thumb': helpers.get_xml_attr(item, 'grandparentThumb'), - 'added_at': helpers.get_xml_attr(item, 'addedAt') + 'added_at': helpers.get_xml_attr(item, 'addedAt'), + 'duration': helpers.get_xml_attr(item, 'duration') if section_type in ('movie', 'episode', 'track') else 0 } if get_media_info: diff --git a/plexpy/webserve.py b/plexpy/webserve.py index c402092f..f84fe019 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -498,6 +498,9 @@ class WebInterface(object): "section_type": "Show", "server_id": "ds48g4r354a8v9byrrtr697g3g79w", "thumb": "/library/metadata/153036/thumb/1462175062", + "total_duration": 3048551210, + "total_size": 62, + "total_storage": 1866078986762, "year": 2016 }, {...}, @@ -519,7 +522,9 @@ class WebInterface(object): ("last_accessed", True, False), ("last_played", True, True), ("plays", True, False), - ("duration", True, False)] + ("duration", True, False), + ("total_storage", True, False), + ("total_duration", True, False)] kwargs['json_data'] = build_datatables_json(kwargs, dt_columns, "section_name") grouping = helpers.bool_true(grouping, return_none=True) @@ -653,6 +658,40 @@ class WebInterface(object): except: return "Failed to update library." + @cherrypy.expose + @cherrypy.tools.json_out() + @requireAuth(member_of("admin")) + @addtoapi() + def get_library_media_stats(self, section_id=None, refresh=''): + """ Get the media stats of a library section on Tautulli. + + ``` + Required parameters: + section_id (str): The id of the Plex library section + refresh (str): "true" to force a refresh of the stats + + Optional parameters: + None + + Returns: + { + "total_duration": 3048551210, + "total_size": 62, + "total_storage": 1866078986762 + } + ``` + """ + + if helpers.bool_true(refresh): + refresh = True + else: + refresh = False + + logger.info("Getting library media stats for section %s.", section_id) + result = libraries.get_library_media_stats(section_id=section_id, refresh=refresh) + + return result + @cherrypy.expose @requireAuth() def library_watch_time_stats(self, section_id=None, **kwargs): @@ -756,12 +795,14 @@ class WebInterface(object): "recordsFiltered": 82, "filtered_file_size": 2616760056742, "total_file_size": 2616760056742, + "total_media_duration": 7947375522, "data": [{"added_at": "1403553078", "audio_channels": "", "audio_codec": "", "bitrate": "", "container": "", + "duration": "", "file_size": 253660175293, "grandparent_rating_key": "", "last_played": 1462380698, @@ -804,6 +845,7 @@ class WebInterface(object): ("video_framerate", True, True), ("audio_codec", True, True), ("audio_channels", True, True), + ("duration", True, False), ("file_size", True, False), ("last_played", True, False), ("play_count", True, False)]