diff --git a/lib/requests_futures/__init__.py b/lib/requests_futures/__init__.py new file mode 100644 index 00000000..04cd3527 --- /dev/null +++ b/lib/requests_futures/__init__.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- + +# Requests Futures + +""" +async requests HTTP library +~~~~~~~~~~~~~~~~~~~~~ + + +""" + +__title__ = 'requests-futures' +__version__ = '0.9.5' +__build__ = 0x000000 +__author__ = 'Ross McFarland' +__license__ = 'Apache 2.0' +__copyright__ = 'Copyright 2013 Ross McFarland' + +# Set default logging handler to avoid "No handler found" warnings. +import logging +try: # Python 2.7+ + from logging import NullHandler +except ImportError: + class NullHandler(logging.Handler): + def emit(self, record): + pass + +logging.getLogger(__name__).addHandler(NullHandler()) diff --git a/lib/requests_futures/sessions.py b/lib/requests_futures/sessions.py new file mode 100644 index 00000000..ad2af1b0 --- /dev/null +++ b/lib/requests_futures/sessions.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +""" +requests_futures +~~~~~~~~~~~~~~~~ + +This module provides a small add-on for the requests http library. It makes use +of python 3.3's concurrent.futures or the futures backport for previous +releases of python. + + from requests_futures import FuturesSession + + session = FuturesSession() + # request is run in the background + future = session.get('http://httpbin.org/get') + # ... do other stuff ... + # wait for the request to complete, if it hasn't already + response = future.result() + print('response status: {0}'.format(response.status_code)) + print(response.content) + +""" + +from concurrent.futures import ThreadPoolExecutor +from requests import Session +from requests.adapters import DEFAULT_POOLSIZE, HTTPAdapter + +class FuturesSession(Session): + + def __init__(self, executor=None, max_workers=2, *args, **kwargs): + """Creates a FuturesSession + + Notes + ~~~~~ + + * ProcessPoolExecutor is not supported b/c Response objects are + not picklable. + + * If you provide both `executor` and `max_workers`, the latter is + ignored and provided executor is used as is. + """ + super(FuturesSession, self).__init__(*args, **kwargs) + if executor is None: + executor = ThreadPoolExecutor(max_workers=max_workers) + # set connection pool size equal to max_workers if needed + if max_workers > DEFAULT_POOLSIZE: + adapter_kwargs = dict(pool_connections=max_workers, + pool_maxsize=max_workers) + self.mount('https://', HTTPAdapter(**adapter_kwargs)) + self.mount('http://', HTTPAdapter(**adapter_kwargs)) + + self.executor = executor + + def request(self, *args, **kwargs): + """Maintains the existing api for Session.request. + + Used by all of the higher level methods, e.g. Session.get. + + The background_callback param allows you to do some processing on the + response in the background, e.g. call resp.json() so that json parsing + happens in the background thread. + """ + func = sup = super(FuturesSession, self).request + + background_callback = kwargs.pop('background_callback', None) + if background_callback: + def wrap(*args_, **kwargs_): + resp = sup(*args_, **kwargs_) + background_callback(self, resp) + return resp + + func = wrap + + return self.executor.submit(func, *args, **kwargs) diff --git a/plexpy/helpers.py b/plexpy/helpers.py index b4f86f49..793494f6 100644 --- a/plexpy/helpers.py +++ b/plexpy/helpers.py @@ -28,7 +28,23 @@ import os import json import xmltodict import math +from functools import wraps +def profile_func(func): + @wraps(func) + def inner(*args, **kwargs): + from plexpy import logger + start = time.time() + res = func(*args, **kwargs) + logger.debug('%s took %s' % (func.__name__, time.time() - start)) + return res + return inner + +def tobool(s): + if s in [1, '1', 'on', 'yes', 'Yes']: + return True + else: + return False def multikeysort(items, columns): comparers = [((itemgetter(col[1:].strip()), -1) if col.startswith('-') else (itemgetter(col.strip()), 1)) for col in columns] diff --git a/plexpy/pmsconnect.py b/plexpy/pmsconnect.py index d52ff65a..27aac26e 100644 --- a/plexpy/pmsconnect.py +++ b/plexpy/pmsconnect.py @@ -13,11 +13,16 @@ # You should have received a copy of the GNU General Public License # along with PlexPy. If not, see . +import urllib2 +import time +import concurrent.futures as cf +from requests_futures.sessions import FuturesSession + from plexpy import logger, helpers, users, http_handler, common from urlparse import urlparse +from request import request_json import plexpy -import urllib2 def get_server_friendly_name(): logger.info("Requesting name from server...") @@ -692,11 +697,225 @@ class PmsConnect(object): output = {'metadata': metadata_list} return output - """ - Return processed and validated session list. - Output: array - """ + def get_watched_status(self, sort=None, sections='all', all_params=False, get_file_size=True, + exclude_path=None, watched_older_then=None, hide_watched=0, ignore_section='', + *args, **kwargs): + + """ + Returns a list of all unwatched shows + + named args: Used for enabled and disabling sorting/filtering + kwargs: Used for filtering inside the dicts. Adding type="movie" will only returns movies + + + Output: List for dicts + + kwargs: Used for filtering inside the dicts. Adding type="movie" will only list movies + + + Output: List for dicts + + # Adding all_params=1 Makes the call insane slow. + """ + # Add a cache? + + use_watched_older_then_sort = True + if watched_older_then is None: + use_watched_older_then_sort = False + watched_older_then = time.time() + else: + watched_older_then = int(watched_older_then) + + if plexpy.CONFIG.PMS_URL: + url_parsed = urlparse(plexpy.CONFIG.PMS_URL) + hostname = url_parsed.hostname + port = url_parsed.port + self.protocol = url_parsed.scheme + else: + hostname = plexpy.CONFIG.PMS_IP + port = plexpy.CONFIG.PMS_PORT + self.protocol = 'http' + + b_url = '%s://%s:%s' % (self.protocol, hostname, port) + + h = {'Accept': 'application/json', + 'X-Plex-Token': plexpy.CONFIG.PMS_TOKEN + } + + hide_watched = 'unwatched' if hide_watched else 'all' + + def fetch(s=''): + result = request_json(b_url + s, headers=h) + if result: + return result + + def maybe_number(v): + try: + v = int(v) + except: + try: + v = float(v) + except: + pass + return v + + result = fetch('/library/sections/all').get('_children') + + # Final results for data grab + f_result = [] + + # List of items and urls passed to req.f + files_urls = [] + + # dicts from req.f is stored here + futures_results = [] + + # Start fetching data + if result: + for sections in result: + if ignore_section != sections['title']: + section = fetch('/library/sections/%s/%s' % (sections['key'], hide_watched)) + for item in section['_children']: + + d = {'title': item.get('title'), + 'type': item['type'], + 'ratingKey': item['ratingKey'], + 'updatedAt': item.get('updatedAt', 0), + 'addedAt': item.get('addedAt', 0), + 'viewCount': item.get('viewCount', 0), + 'files': [], + 'lastViewedAt': item.get('lastViewedAt', 0), + 'viewOffset': item.get('viewOffset', 0), + 'spaceUsed': 0, # custom + 'isSeen': 0, # custom + '_elementType': item['_elementType'] + } + + # Only movies has the files listed here + if item['_elementType'] == 'Video': + d['viewCount'] = item.get('viewCount', 0) + if item.get('viewCount', 0) > 0: + d['isSeen'] = 1 + + + # grab the file names and size + if '_children' in item: + for c in item['_children']: + if '_children' in c: + for part in c['_children']: + f = part.get('file') + s = part.get('size', 0) + d['spaceUsed'] += s + if f and s: + d['files'].append({'size': s, 'file': f}) + + f_result.append(d) + + + #elif item['_elementType'] == 'Track': + # # I dont think it returns a "Track" as all + # pass + + elif item['_elementType'] == 'Directory': + + if item['type'] == 'show' or item['type'] == 'artist': + d['viewedLeafCount'] = item.get('viewedLeafCount', 0) + d['leafCount'] = item.get('leafCount', 0) + d['_elementType'] = item['_elementType'] + if item['type'] == 'show': + # We are gonna assume a entire show is watched + # Might be false it someone watched the same ep + # over and over + if d['viewedLeafCount'] >= d['leafCount'] and d['viewedLeafCount'] > 0: + d['viewCount'] = item['viewedLeafCount'] # only set to easy filter + d['isSeen'] = 1 + + elif item['type'] == 'artist': + d['isSeen'] = 1 if d['viewCount'] > 0 else 0 + + if get_file_size: # Runs faster without + files_urls.append((item, b_url + '/library/metadata/' + str(item['ratingKey']) + '/allLeaves')) + else: + f_result.append(d) + + #elif item['type'] == 'artist': + # pass # Handled above left for future stuff + + + t_result = f_result + + if get_file_size: + future_sess = FuturesSession(max_workers=8) + futs = {} # Future holder + for zomg in files_urls: + t = future_sess.get(zomg[1], timeout=5, headers=h) + futs[t] = zomg[0] + + for fut_obj in cf.as_completed(futs): + try: + res = fut_obj.result() + jsn = res.json() + f_item = futs[fut_obj] + # Add the same dict as movies.. + d = {'title': f_item.get('title'), + 'type': f_item['type'], + 'ratingKey': f_item['ratingKey'], + 'updatedAt': f_item.get('updatedAt', 0), + 'addedAt': f_item.get('addedAt', 0), + 'viewCount': f_item.get('viewCount', 0), + 'files': [], + 'lastViewedAt': f_item.get('lastViewedAt', 0), + 'viewOffset': f_item.get('viewOffset', 0), + 'spaceUsed': 0, # custom + 'isSeen': 0, # custom + } + if f_item['_elementType'] == 'Directory': + if f_item['type'] in ['show', 'artist']: + if f_item['type'] == 'show': + # We are gonna assume the user has watched if + d['isSeen'] = 1 if f_item['leafCount'] >= f_item['viewedLeafCount'] and f_item['viewedLeafCount'] > 0 else 0 + d['viewCount'] = f_item['viewedLeafCount'] + elif f_item['type'] == 'artist': + d['isSeen'] = 1 if d['viewCount'] > 0 else 0 + if '_children' in jsn: + for e in jsn['_children']: + if '_children' in e: + for c in e['_children']: + if '_children' in c: + for cc in c['_children']: + f = cc.get('file') + s = cc.get('size', 0) + d['spaceUsed'] += s + if f and s: + d['files'].append({'size': s, 'file': f}) + futures_results.append(d) + + except Exception as error: + logger.error('Error while trying to get/process a future %s' % error) + + + t_result = t_result + futures_results + + # If any user given kwargs was given filter on them. + if kwargs: + logger.debug('kwargs was given %s filtering the dicts based on them' % kwargs) + if not all_params: + t_result = [d for d in t_result if any(d.get(k) == maybe_number(kwargs[k]) for k in kwargs)] + else: + logger.debug('All kwargs is required to be in the list') + t_result = [d for d in t_result if all(d.get(k, None) == maybe_number(kwargs[k]) for k in kwargs)] + + if use_watched_older_then_sort: + t_result = [i for i in t_result if not i['viewCount'] or i['lastViewedAt'] <= watched_older_then] + + if sort: + logger.debug('Sorted on %s' % sort) + t_result = sorted(t_result, key=lambda k: k[sort], reverse=True) + + return t_result + + def get_current_activity(self): session_data = self.get_sessions(output_format='xml') diff --git a/plexpy/webserve.py b/plexpy/webserve.py index e4831cbc..6fe789f7 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -14,7 +14,7 @@ # along with PlexPy. If not, see . from plexpy import logger, notifiers, plextv, pmsconnect, common, log_reader, datafactory, graphs, users, helpers -from plexpy.helpers import checked, radio +from plexpy.helpers import checked, radio, profile_func, tobool from mako.lookup import TemplateLookup from mako import exceptions @@ -923,6 +923,20 @@ class WebInterface(object): else: logger.warn('Unable to retrieve data.') + @profile_func + @cherrypy.tools.json_out() + @cherrypy.expose + def get_watched(self, sort=None, sections='all', all_params=False, + get_file_size=True, exclude_path=None, watched_older_then=None, + hide_watched=0, ignore_section='', **kwargs): + """ See get_watched_status for docs""" + all_params = tobool(all_params) + get_file_size = tobool(get_file_size) + hide_watched = tobool(hide_watched) + pms_connect = pmsconnect.PmsConnect() + t = pms_connect.get_watched_status(sort=sort, sections=sections, all_params=all_params, get_file_size=get_file_size, exclude_path=exclude_path, hide_watched=hide_watched, watched_older_then=None, ignore_section=ignore_section, **kwargs) + return t + @cherrypy.expose def get_episode_list_json(self, rating_key='', **kwargs):