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):