diff --git a/data/interfaces/default/history_new.html b/data/interfaces/default/history_new.html new file mode 100644 index 00000000..d425d053 --- /dev/null +++ b/data/interfaces/default/history_new.html @@ -0,0 +1,80 @@ +<%inherit file="base.html"/> +<%! +from plexpy import helpers +%> + +<%def name="headIncludes()"> + + + + +<%def name="body()"> + +
+
+
+
+
+

History

+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
IDTimeUserPlatformIP AddressTitleStartedPausedStoppedDurationCompletedgrandparentRatingKeyRatingKey
+ +
+
+
+
+ + + + +<%def name="javascriptIncludes()"> + + + + + + + + + diff --git a/data/interfaces/default/js/tables/history_table_new.js b/data/interfaces/default/js/tables/history_table_new.js new file mode 100644 index 00000000..f5d9dd2b --- /dev/null +++ b/data/interfaces/default/js/tables/history_table_new.js @@ -0,0 +1,217 @@ +var date_format = 'YYYY-MM-DD hh:mm'; +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; + } +}); + +history_table_options = { + "destroy": true, + "responsive": { + details: false + }, + "language": { + "search": "Search: ", + "lengthMenu":"Show _MENU_ entries per page", + "info":"Showing _START_ to _END_ of _TOTAL_ history items", + "infoEmpty":"Showing 0 to 0 of 0 entries", + "infoFiltered":"(filtered from _MAX_ total entries)", + "emptyTable": "No data in table", + }, + "stateSave": false, + "sPaginationType": "bootstrap", + "processing": false, + "serverSide": true, + "pageLength": 25, + "order": [ 1, 'desc'], + "columnDefs": [ + { + "targets": [0], + "data":"id", + "visible": false, + "searchable": false, + "className": "no-wrap" + }, + { + "targets": [1], + "data":"date", + "createdCell": function (td, cellData, rowData, row, col) { + if (rowData['stopped'] === null) { + $(td).addClass('currentlyWatching'); + $(td).html('Currently watching...'); + } else { + $(td).html(moment(cellData,"X").format(date_format)); + } + }, + "searchable": false, + "className": "no-wrap" + }, + { + "targets": [2], + "data":"friendly_name", + "createdCell": function (td, cellData, rowData, row, col) { + if (cellData !== '') { + $(td).html('' + cellData + ''); + } else { + $(td).html(cellData); + } + }, + "className": "no-wrap" + }, + { + "targets": [3], + "data":"platform", + "createdCell": function (td, cellData, rowData, row, col) { + if (cellData !== '') { + $(td).html(' '+cellData); + } + }, + "className": "modal-control no-wrap" + }, + { + "targets": [4], + "data":"ip_address", + "createdCell": function (td, cellData, rowData, row, col) { + if ((cellData == '') || (cellData == '0')) { + $(td).html('n/a'); + } + }, + "className": "no-wrap" + }, + { + "targets": [5], + "data":"title", + "name":"title", + "createdCell": function (td, cellData, rowData, row, col) { + if (cellData !== '') { + if (rowData['media_type'] === 'movie' || rowData['media_type'] === 'episode') { + $(td).html('
' + cellData + '
'); + } else if (rowData['media_type'] === 'track') { + $(td).html('
' + cellData + '
'); + } else { + $(td).html('' + cellData + ''); + } + } + } + }, + { + "targets": [6], + "data":"started", + "render": function ( data, type, full ) { + return moment(data, "X").format(time_format); + }, + "searchable": false, + "className": "no-wrap" + }, + { + "targets": [7], + "data":"paused_counter", + "render": function ( data, type, full ) { + return Math.round(moment.duration(data, 'seconds').as('minutes')) + ' mins'; + }, + "searchable": false, + "className": "no-wrap" + }, + { + "targets": [8], + "data":"stopped", + "render": function ( data, type, full ) { + if (data !== null) { + return moment(data, "X").format(time_format); + } else { + return data; + } + }, + "searchable": false, + "className": "no-wrap" + }, + { + "targets": [9], + "data":"duration", + "render": function ( data, type, full ) { + if (data !== null) { + return Math.round(moment.duration(data, 'seconds').as('minutes')) + ' mins'; + } else { + return data; + } + }, + "searchable": false, + "className": "no-wrap" + }, + { + "targets": [10], + "data":"percent_complete", + "render": function ( data, type, full ) { + if (data < 85) { + return ''+Math.round(data)+'%'; + } else { + return '100%'; + } + }, + "searchable": false, + "className": "no-wrap" + }, + { + "targets": [11], + "data":"grandparent_rating_key", + "visible": false, + "searchable": false + }, + { + "targets": [12], + "data":"rating_key", + "visible": false, + "searchable": false + }, + { + "targets": [13], + "data":"media_type", + "searchable":false, + "visible":false + }, + { + "targets": [14], + "data":"user", + "searchable":false, + "visible":false + } + + ], + "drawCallback": function (settings) { + // Jump to top of page + // $('html,body').scrollTop(0); + $('#ajaxMsg').addClass('success').fadeOut(); + }, + "preDrawCallback": function(settings) { + $('#ajaxMsg').html("
 Fetching rows...
"); + $('#ajaxMsg').addClass('success').fadeIn(); + } +} + +$('#history_table').on('mouseenter', 'td.modal-control span', function () { + $(this).tooltip(); +}); + +$('#history_table').on('click', 'td.modal-control', function () { + var tr = $(this).parents('tr'); + var row = history_table.row( tr ); + var rowData = row.data(); + + function showStreamDetails() { + $.ajax({ + url: 'get_stream_data', + data: {row_id: rowData['id'], user: rowData['friendly_name']}, + cache: false, + async: true, + complete: function(xhr, status) { + $("#info-modal").html(xhr.responseText); + } + }); + } + showStreamDetails(); +}); \ 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 42c408ad..9d670b12 100644 --- a/data/interfaces/default/js/tables/users.js +++ b/data/interfaces/default/js/tables/users.js @@ -15,9 +15,6 @@ users_list_table_options = { "serverSide": true, "pageLength": 10, "order": [ 1, 'asc'], - "ajax": { - "url": "get_user_list" - }, "autoWidth": true, "stateSave": true, "sPaginationType": "bootstrap", diff --git a/data/interfaces/default/users.html b/data/interfaces/default/users.html index a9d85e7e..b75d63bb 100644 --- a/data/interfaces/default/users.html +++ b/data/interfaces/default/users.html @@ -58,6 +58,10 @@ from plexpy import helpers + + + + + + diff --git a/plexpy/datafactory.py b/plexpy/datafactory.py new file mode 100644 index 00000000..c5f9fbb2 --- /dev/null +++ b/plexpy/datafactory.py @@ -0,0 +1,259 @@ +# This file is part of PlexPy. +# +# PlexPy 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. +# +# PlexPy 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 PlexPy. If not, see . + +from plexpy import logger, helpers, datatables_new, common, monitor +from xml.dom import minidom + +import datetime +import plexpy + + +class DataFactory(object): + """ + Retrieve and process data from the plexwatch database + """ + + def __init__(self): + pass + + def get_user_list(self, start='', length='', kwargs=None): + data_tables = datatables_new.DataTables() + + start = int(start) + length = int(length) + filtered = [] + totalcount = 0 + search_value = "" + search_regex = "" + order_column = 1 + order_dir = "desc" + + if 'order[0][dir]' in kwargs: + order_dir = kwargs.get('order[0][dir]', "desc") + + if 'order[0][column]' in kwargs: + order_column = kwargs.get('order[0][column]', 1) + + if 'search[value]' in kwargs: + search_value = kwargs.get('search[value]', "") + + if 'search[regex]' in kwargs: + search_regex = kwargs.get('search[regex]', "") + + t1 = 'session_history' + t2 = 'session_history_metadata' + t3 = 'users' + + columns = [t1 + '.id', + '(case when users.friendly_name is null then ' + t1 + + '.user else users.friendly_name end) as friendly_name', + t1 + '.started', + t1 + '.ip_address', + 'COUNT(' + t1 + '.rating_key) as plays', + t1 + '.user', + t1 + '.user_id', + 'users.thumb as thumb'] + try: + query = data_tables.ssp_query(table_name=t1, + columns=columns, + start=start, + length=length, + order_column=int(order_column), + order_dir=order_dir, + search_value=search_value, + search_regex=search_regex, + custom_where='', + group_by=(t1 + '.user'), + join_type=['LEFT OUTER JOIN'], + join_table=['users'], + join_evals=[[t1 + '.user', 'users.username']], + kwargs=kwargs) + except: + logger.warn("Unable to open session_history table.") + return {'recordsFiltered': 0, + 'recordsTotal': 0, + 'data': 'null'}, + + users = query['result'] + + rows = [] + for item in users: + if not item['thumb'] or item['thumb'] == '': + user_thumb = common.DEFAULT_USER_THUMB + else: + user_thumb = item['thumb'] + + row = {"plays": item['plays'], + "time": item['started'], + "friendly_name": item["friendly_name"], + "ip_address": item["ip_address"], + "thumb": user_thumb, + "user": item["user"], + "user_id": item['user_id'] + } + + rows.append(row) + + dict = {'recordsFiltered': query['filteredCount'], + 'recordsTotal': query['totalCount'], + 'data': rows, + } + + return dict + + def get_history(self, start='', length='', kwargs=None, custom_where=''): + data_tables = datatables_new.DataTables() + + start = int(start) + length = int(length) + filtered = [] + totalcount = 0 + search_value = "" + search_regex = "" + order_column = 1 + order_dir = "desc" + + t1 = 'session_history' + t2 = 'session_history_metadata' + t3 = 'users' + t4 = 'session_history_media_info' + + if 'order[0][dir]' in kwargs: + order_dir = kwargs.get('order[0][dir]', "desc") + + if 'order[0][column]' in kwargs: + order_column = kwargs.get('order[0][column]', "1") + + if 'search[value]' in kwargs: + search_value = kwargs.get('search[value]', "") + + if 'search[regex]' in kwargs: + search_regex = kwargs.get('search[regex]', "") + + columns = [t1 + '.id', + t1 + '.started as date', + '(CASE WHEN users.friendly_name IS NULL THEN ' + t1 + + '.user ELSE users.friendly_name END) as friendly_name', + t1 + '.player', + t1 + '.ip_address', + t2 + '.title', + t1 + '.started', + t1 + '.paused_counter', + t1 + '.stopped', + 'round((julianday(datetime(' + t1 + '.stopped, "unixepoch", "localtime")) - \ + julianday(datetime(' + t1 + '.started, "unixepoch", "localtime"))) * 86400) - \ + (CASE WHEN ' + t1 + '.paused_counter IS NULL THEN 0 ELSE ' + t1 + '.paused_counter END) as duration', + '((CASE WHEN ' + t1 + '.view_offset IS NULL THEN 0.0 ELSE ' + t1 + '.view_offset * 1.0 END) / \ + (CASE WHEN ' + t2 + '.duration IS NULL THEN 1.0 ELSE ' + t2 + '.duration * 1.0 END) * 100) as percent_complete', + t1 + '.grandparent_rating_key as grandparent_rating_key', + t1 + '.rating_key as rating_key', + t1 + '.user', + t2 + '.media_type' + ] + try: + query = data_tables.ssp_query(table_name=t1, + columns=columns, + start=start, + length=length, + order_column=int(order_column), + order_dir=order_dir, + search_value=search_value, + search_regex=search_regex, + custom_where=custom_where, + group_by='', + join_type=['JOIN', 'JOIN'], + join_table=[t3, t2], + join_evals=[[t1 + '.user_id', t3 + '.user_id'], [t1 + '.id', t2 + '.id']], + kwargs=kwargs) + except: + logger.warn("Unable to open PlexWatch database.") + return {'recordsFiltered': 0, + 'recordsTotal': 0, + 'data': 'null'}, + + history = query['result'] + + rows = [] + # NOTE: We are adding in a blank xml field in order enable the Datatables "searchable" parameter + for item in history: + row = {"id": item['id'], + "date": item['date'], + "friendly_name": item['friendly_name'], + "platform": item["player"], + "ip_address": item["ip_address"], + "title": item["title"], + "started": item["started"], + "paused_counter": item["paused_counter"], + "stopped": item["stopped"], + "duration": item["duration"], + "percent_complete": round(item["percent_complete"], 0), + "grandparent_rating_key": item["grandparent_rating_key"], + "rating_key": item["rating_key"], + "user": item["user"], + "media_type": item["media_type"] + } + + if item['paused_counter'] > 0: + row['paused_counter'] = item['paused_counter'] + else: + row['paused_counter'] = 0 + + if item['started']: + if item['stopped'] > 0: + stopped = item['stopped'] + else: + stopped = 0 + if item['paused_counter'] > 0: + paused_counter = item['paused_counter'] + else: + paused_counter = 0 + + rows.append(row) + + dict = {'recordsFiltered': query['filteredCount'], + 'recordsTotal': query['totalCount'], + 'data': rows, + } + + return dict + + def set_user_friendly_name(self, user=None, friendly_name=None): + if user: + if friendly_name.strip() == '': + friendly_name = None + + monitor_db = monitor.MonitorDatabase() + + control_value_dict = {"username": user} + new_value_dict = {"friendly_name": friendly_name} + try: + monitor_db.upsert('users', new_value_dict, control_value_dict) + except Exception, e: + logger.debug(u"Uncaught exception %s" % e) + + def get_user_friendly_name(self, user=None): + if user: + try: + monitor_db = monitor.MonitorDatabase() + query = 'select friendly_name FROM users WHERE username = ?' + result = monitor_db.select_single(query, args=[user]) + if result: + return result + else: + return user + except: + return user + + return None diff --git a/plexpy/datatables_new.py b/plexpy/datatables_new.py new file mode 100644 index 00000000..f2a96ed3 --- /dev/null +++ b/plexpy/datatables_new.py @@ -0,0 +1,222 @@ +# This file is part of PlexPy. +# +# PlexPy 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. +# +# PlexPy 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 PlexPy. If not, see . + +# TODO: Implement with sqlite3 directly instead of using db class + +from plexpy import logger, helpers, monitor + +import re + + +class DataTables(object): + """ + Server side processing for Datatables + """ + + def __init__(self): + self.ssp_db = monitor.MonitorDatabase() + logger.debug(u"Database initilised!") + + # TODO: Pass all parameters via kwargs + def ssp_query(self, table_name, + columns=[], + start=0, + length=0, + order_column=0, + order_dir='asc', + search_value='', + search_regex='', + custom_where='', + group_by='', + join_type=None, + join_table=None, + join_evals=None, + kwargs=None): + + parameters = self.process_kwargs(kwargs) + + if group_by != '': + grouping = True + else: + grouping = False + + column_data = self.extract_columns(columns) + where = self.construct_where(column_data, search_value, grouping, parameters) + order = self.construct_order(column_data, order_column, order_dir, parameters, table_name, grouping) + join = '' + + if join_type: + join_iter = 0 + for join_type_item in join_type: + if join_type_item.upper() == 'LEFT OUTER JOIN': + join_item = 'LEFT OUTER JOIN %s ON %s = %s ' % \ + (join_table[join_iter], join_evals[join_iter][0], join_evals[join_iter][1]) + elif join_type_item.upper() == 'JOIN' or join_type.upper() == 'INNER JOIN': + join_item = 'INNER JOIN %s ON %s = %s ' % \ + (join_table[join_iter], join_evals[join_iter][0], join_evals[join_iter][1]) + else: + join_item = '' + join_iter += 1 + join += join_item + + logger.debug(u"join string = %s" % join) + + # TODO: custom_where is ugly and causes issues with reported total results + if custom_where != '': + custom_where = 'WHERE (' + custom_where + ')' + + if grouping: + if custom_where == '': + query = 'SELECT * FROM (SELECT %s FROM %s %s GROUP BY %s) %s %s' \ + % (column_data['column_string'], table_name, join, group_by, + where, order) + else: + query = 'SELECT * FROM (SELECT * FROM (SELECT %s FROM %s %s GROUP BY %s) %s %s) %s' \ + % (column_data['column_string'], table_name, join, group_by, + where, order, custom_where) + else: + if custom_where == '': + query = 'SELECT %s FROM %s %s %s %s' \ + % (column_data['column_string'], table_name, join, where, + order) + else: + query = 'SELECT * FROM (SELECT %s FROM %s %s %s %s) %s' \ + % (column_data['column_string'], table_name, join, where, + order, custom_where) + + logger.debug(u"Query string: %s" % query) + filtered = self.ssp_db.select(query) + + if search_value == '': + totalcount = len(filtered) + else: + totalcount = self.ssp_db.select('SELECT COUNT(*) from %s' % table_name)[0][0] + + result = filtered[start:(start + length)] + output = {'result': result, + 'filteredCount': len(filtered), + 'totalCount': totalcount} + + return output + + @staticmethod + def construct_order(column_data, order_column, order_dir, parameters=None, table_name=None, grouped=False): + order = '' + if grouped: + sort_col = column_data['column_named'][order_column] + else: + sort_col = column_data['column_order'][order_column] + if parameters: + for parameter in parameters: + if parameter['data'] != '': + if int(order_column) == parameter['index']: + if parameter['data'] in column_data['column_named'] and parameter['orderable'] == 'true': + if table_name and table_name != '': + order = 'ORDER BY %s COLLATE NOCASE %s' % (sort_col, order_dir) + else: + order = 'ORDER BY %s COLLATE NOCASE %s' % (sort_col, order_dir) + else: + order = 'ORDER BY %s COLLATE NOCASE %s' % (sort_col, order_dir) + + return order + + @staticmethod + def construct_where(column_data, search_value='', grouping=False, parameters=None): + if search_value != '': + where = 'WHERE ' + if parameters: + for column in column_data['column_named']: + search_skip = False + for parameter in parameters: + if column.rpartition('.')[-1] in parameter['data']: + if parameter['searchable'] == 'true': + where += column + ' LIKE "%' + search_value + '%" OR ' + search_skip = True + else: + search_skip = True + + if not search_skip: + where += column + ' LIKE "%' + search_value + '%" OR ' + else: + for column in column_data['column_named']: + where += column + ' LIKE "%' + search_value + '%" OR ' + + # TODO: This will break the query if all parameters are excluded + where = where[:-4] + + return where + else: + where = '' + + return where + + @staticmethod + def extract_columns(columns=[]): + columns_string = '' + columns_literal = [] + columns_named = [] + columns_order = [] + + for column in columns: + columns_string += column + columns_string += ', ' + # TODO: make this case insensitive + if ' as ' in column: + columns_literal.append(column.rpartition(' as ')[0]) + columns_named.append(column.rpartition(' as ')[-1].rpartition('.')[-1]) + columns_order.append(column.rpartition(' as ')[-1]) + else: + columns_literal.append(column) + columns_named.append(column.rpartition('.')[-1]) + columns_order.append(column) + + columns_string = columns_string[:-2] + + column_data = {'column_string': columns_string, + 'column_literal': columns_literal, + 'column_named': columns_named, + 'column_order': columns_order + } + + return column_data + + # TODO: Fix this method. Should not break if kwarg list is not sorted. + def process_kwargs(self, kwargs): + + column_parameters = [] + + for kwarg in sorted(kwargs): + if re.search(r"\[(\w+)\]", kwarg) and kwarg[:7] == 'columns': + parameters = re.findall(r"\[(\w+)\]", kwarg) + array_index = '' + for parameter in parameters: + pass_complete = False + if parameter.isdigit(): + array_index = parameter + if parameter == 'data': + data = kwargs.get('columns[' + array_index + '][data]', "") + if parameter == 'orderable': + orderable = kwargs.get('columns[' + array_index + '][orderable]', "") + if parameter == 'searchable': + searchable = kwargs.get('columns[' + array_index + '][searchable]', "") + pass_complete = True + if pass_complete: + row = {'index': int(array_index), + 'data': data, + 'searchable': searchable, + 'orderable': orderable} + column_parameters.append(row) + + return sorted(column_parameters, key=lambda i: i['index']) \ No newline at end of file diff --git a/plexpy/plextv.py b/plexpy/plextv.py index 99a6a452..f998dd60 100644 --- a/plexpy/plextv.py +++ b/plexpy/plextv.py @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . -from plexpy import logger, helpers, plexwatch, db, http_handler +from plexpy import logger, helpers, plexwatch, db, http_handler, monitor from xml.dom import minidom @@ -24,6 +24,7 @@ def refresh_users(): logger.info("Requesting users list refresh...") result = PlexTV().get_full_users_list() pw_db = db.DBConnection() + monitor_db = monitor.MonitorDatabase() if len(result) > 0: for item in result: @@ -38,6 +39,7 @@ def refresh_users(): } pw_db.upsert('plexpy_users', new_value_dict, control_value_dict) + monitor_db.upsert('users', new_value_dict, control_value_dict) logger.info("Users list refreshed.") else: diff --git a/plexpy/webserve.py b/plexpy/webserve.py index ebb239a2..aa7713ca 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -13,7 +13,7 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . -from plexpy import logger, notifiers, plextv, pmsconnect, plexwatch, db, common, log_reader +from plexpy import logger, notifiers, plextv, pmsconnect, plexwatch, db, common, log_reader, datafactory from plexpy.helpers import checked, radio from mako.lookup import TemplateLookup @@ -93,10 +93,18 @@ class WebInterface(object): def history(self): return serve_template(templatename="history.html", title="History") + @cherrypy.expose + def history_new(self): + return serve_template(templatename="history_new.html", title="History") + @cherrypy.expose def users(self): return serve_template(templatename="users.html", title="Users") + @cherrypy.expose + def users_new(self): + return serve_template(templatename="users_new.html", title="Users") + @cherrypy.expose def graphs(self): return serve_template(templatename="graphs.html", title="Graphs") @@ -140,6 +148,11 @@ class WebInterface(object): try: plex_watch = plexwatch.PlexWatch() plex_watch.set_user_friendly_name(user, friendly_name) + + # For the new database too + data_factory = datafactory.DataFactory() + data_factory.set_user_friendly_name(user, friendly_name) + status_message = "Successfully updated user." return status_message except: @@ -163,6 +176,15 @@ class WebInterface(object): cherrypy.response.headers['Content-type'] = 'application/json' return json.dumps(users) + @cherrypy.expose + def get_user_list_new(self, start=0, length=100, **kwargs): + + data_factory = datafactory.DataFactory() + users = data_factory.get_user_list(start, length, kwargs) + + cherrypy.response.headers['Content-type'] = 'application/json' + return json.dumps(users) + @cherrypy.expose def checkGithub(self): from plexpy import versioncheck @@ -424,6 +446,25 @@ class WebInterface(object): cherrypy.response.headers['Content-type'] = 'application/json' return json.dumps(history) + @cherrypy.expose + def get_history_new(self, start=0, length=100, custom_where='', **kwargs): + + if 'user' in kwargs: + user = kwargs.get('user', "") + custom_where = 'user = "%s"' % user + if 'rating_key' in kwargs: + rating_key = kwargs.get('rating_key', "") + custom_where = 'rating_key = %s' % rating_key + if 'grandparent_rating_key' in kwargs: + rating_key = kwargs.get('grandparent_rating_key', "") + custom_where = 'grandparent_rating_key = %s' % rating_key + + data_factory = datafactory.DataFactory() + history = data_factory.get_history(start, length, kwargs, custom_where) + + cherrypy.response.headers['Content-type'] = 'application/json' + return json.dumps(history) + @cherrypy.expose def get_stream_details(self, rating_key=0, **kwargs):