2016-03-21 04:26:02 +00:00
|
|
|
# -*- coding: utf-8 -*-
|
2014-12-29 03:21:58 +00:00
|
|
|
"""
|
|
|
|
PlexLibrary
|
|
|
|
"""
|
2016-03-31 20:52:48 +00:00
|
|
|
from plexapi import log, utils
|
|
|
|
from plexapi import X_PLEX_CONTAINER_SIZE
|
2016-03-31 22:36:54 +00:00
|
|
|
from plexapi.media import MediaTag
|
2016-03-31 20:52:48 +00:00
|
|
|
from plexapi.exceptions import BadRequest, NotFound
|
2014-12-29 03:21:58 +00:00
|
|
|
|
|
|
|
|
|
|
|
class Library(object):
|
2015-06-08 16:41:47 +00:00
|
|
|
|
2014-12-29 03:21:58 +00:00
|
|
|
def __init__(self, server, data):
|
|
|
|
self.identifier = data.attrib.get('identifier')
|
|
|
|
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
|
2016-04-12 02:43:21 +00:00
|
|
|
self.server = server
|
2014-12-29 03:21:58 +00:00
|
|
|
self.title1 = data.attrib.get('title1')
|
|
|
|
self.title2 = data.attrib.get('title2')
|
2016-04-12 02:43:21 +00:00
|
|
|
self._sectionsByID = {} # cached section UUIDs
|
2014-12-29 03:21:58 +00:00
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
return '<Library:%s>' % self.title1.encode('utf8')
|
|
|
|
|
|
|
|
def sections(self):
|
|
|
|
items = []
|
2016-03-15 02:20:02 +00:00
|
|
|
SECTION_TYPES = {
|
|
|
|
MovieSection.TYPE: MovieSection,
|
|
|
|
ShowSection.TYPE: ShowSection,
|
|
|
|
MusicSection.TYPE: MusicSection,
|
2016-04-10 03:59:47 +00:00
|
|
|
PhotoSection.TYPE: PhotoSection,
|
2016-03-15 02:20:02 +00:00
|
|
|
}
|
2014-12-29 03:21:58 +00:00
|
|
|
path = '/library/sections'
|
|
|
|
for elem in self.server.query(path):
|
|
|
|
stype = elem.attrib['type']
|
|
|
|
if stype in SECTION_TYPES:
|
|
|
|
cls = SECTION_TYPES[stype]
|
2016-04-12 02:43:21 +00:00
|
|
|
section = cls(self.server, elem, path)
|
|
|
|
self._sectionsByID[section.key] = section
|
|
|
|
items.append(section)
|
2014-12-29 03:21:58 +00:00
|
|
|
return items
|
|
|
|
|
|
|
|
def section(self, title=None):
|
|
|
|
for item in self.sections():
|
|
|
|
if item.title == title:
|
|
|
|
return item
|
|
|
|
raise NotFound('Invalid library section: %s' % title)
|
2016-04-12 02:43:21 +00:00
|
|
|
|
|
|
|
def sectionByID(self, sectionID):
|
|
|
|
if not self._sectionsByID:
|
|
|
|
self.sections()
|
|
|
|
return self._sectionsByID[sectionID]
|
2014-12-29 03:21:58 +00:00
|
|
|
|
|
|
|
def all(self):
|
2016-03-21 04:26:02 +00:00
|
|
|
return utils.listItems(self.server, '/library/all')
|
2014-12-29 03:21:58 +00:00
|
|
|
|
|
|
|
def onDeck(self):
|
2016-03-21 04:26:02 +00:00
|
|
|
return utils.listItems(self.server, '/library/onDeck')
|
2014-12-29 03:21:58 +00:00
|
|
|
|
|
|
|
def recentlyAdded(self):
|
2016-03-21 04:26:02 +00:00
|
|
|
return utils.listItems(self.server, '/library/recentlyAdded')
|
2014-12-29 03:21:58 +00:00
|
|
|
|
|
|
|
def get(self, title):
|
2016-03-21 04:26:02 +00:00
|
|
|
return utils.findItem(self.server, '/library/all', title)
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2015-06-02 02:28:50 +00:00
|
|
|
def getByKey(self, key):
|
2016-03-21 04:26:02 +00:00
|
|
|
return utils.findKey(self.server, key)
|
2016-03-15 18:36:59 +00:00
|
|
|
|
2016-04-08 02:48:45 +00:00
|
|
|
def search(self, title=None, libtype=None, **kwargs):
|
2016-03-31 22:39:08 +00:00
|
|
|
""" Searching within a library section is much more powerful. It seems certain attributes on the media
|
|
|
|
objects can be targeted to filter this search down a bit, but I havent found the documentation for
|
|
|
|
it. For example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items
|
|
|
|
such as actor=<id> seem to work, but require you already know the id of the actor.
|
|
|
|
TLDR: This is untested but seems to work. Use library section search when you can.
|
2016-03-31 20:52:48 +00:00
|
|
|
"""
|
2016-01-19 09:50:31 +00:00
|
|
|
args = {}
|
|
|
|
if title: args['title'] = title
|
2016-03-21 04:26:02 +00:00
|
|
|
if libtype: args['type'] = utils.searchType(libtype)
|
2016-03-31 20:52:48 +00:00
|
|
|
for attr, value in kwargs.items():
|
|
|
|
args[attr] = value
|
|
|
|
query = '/library/all%s' % utils.joinArgs(args)
|
2016-03-21 04:26:02 +00:00
|
|
|
return utils.listItems(self.server, query)
|
2016-03-31 20:52:48 +00:00
|
|
|
|
2014-12-29 03:21:58 +00:00
|
|
|
def cleanBundles(self):
|
|
|
|
self.server.query('/library/clean/bundles')
|
|
|
|
|
|
|
|
def emptyTrash(self):
|
|
|
|
for section in self.sections():
|
|
|
|
section.emptyTrash()
|
|
|
|
|
|
|
|
def optimize(self):
|
|
|
|
self.server.query('/library/optimize')
|
|
|
|
|
|
|
|
def refresh(self):
|
|
|
|
self.server.query('/library/sections/all/refresh')
|
|
|
|
|
|
|
|
|
|
|
|
class LibrarySection(object):
|
2016-03-31 20:52:48 +00:00
|
|
|
ALLOWED_FILTERS = ()
|
|
|
|
ALLOWED_SORT = ()
|
|
|
|
BOOLEAN_FILTERS = ('unwatched', 'duplicate')
|
2014-12-29 03:21:58 +00:00
|
|
|
|
|
|
|
def __init__(self, server, data, initpath):
|
|
|
|
self.server = server
|
|
|
|
self.initpath = initpath
|
2016-04-12 02:43:21 +00:00
|
|
|
self.agent = data.attrib.get('agent')
|
|
|
|
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
|
|
|
|
self.art = data.attrib.get('art')
|
|
|
|
self.composite = data.attrib.get('composite')
|
|
|
|
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
|
|
|
|
self.filters = data.attrib.get('filters')
|
2014-12-29 03:21:58 +00:00
|
|
|
self.key = data.attrib.get('key')
|
|
|
|
self.language = data.attrib.get('language')
|
2016-04-12 02:43:21 +00:00
|
|
|
self.language = data.attrib.get('language')
|
|
|
|
self.locations = utils.findLocations(data)
|
|
|
|
self.refreshing = utils.cast(bool, data.attrib.get('refreshing'))
|
|
|
|
self.scanner = data.attrib.get('scanner')
|
|
|
|
self.thumb = data.attrib.get('thumb')
|
|
|
|
self.title = data.attrib.get('title')
|
|
|
|
self.type = data.attrib.get('type')
|
|
|
|
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
|
|
|
self.uuid = data.attrib.get('uuid')
|
2014-12-29 03:21:58 +00:00
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
title = self.title.replace(' ','.')[0:20]
|
|
|
|
return '<%s:%s>' % (self.__class__.__name__, title.encode('utf8'))
|
2016-03-31 20:52:48 +00:00
|
|
|
|
2014-12-29 03:21:58 +00:00
|
|
|
def get(self, title):
|
|
|
|
path = '/library/sections/%s/all' % self.key
|
2016-03-21 04:26:02 +00:00
|
|
|
return utils.findItem(self.server, path, title)
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2016-03-31 20:52:48 +00:00
|
|
|
def all(self):
|
|
|
|
return utils.listItems(self.server, '/library/sections/%s/all' % self.key)
|
|
|
|
|
|
|
|
def onDeck(self):
|
|
|
|
return utils.listItems(self.server, '/library/sections/%s/onDeck' % self.key)
|
|
|
|
|
2016-04-01 03:39:09 +00:00
|
|
|
def analyze(self):
|
|
|
|
self.server.query('/library/sections/%s/analyze' % self.key)
|
|
|
|
|
|
|
|
def emptyTrash(self):
|
|
|
|
self.server.query('/library/sections/%s/emptyTrash' % self.key)
|
|
|
|
|
|
|
|
def refresh(self):
|
|
|
|
self.server.query('/library/sections/%s/refresh' % self.key)
|
|
|
|
|
2016-03-31 20:52:48 +00:00
|
|
|
def listChoices(self, category, libtype=None, **kwargs):
|
|
|
|
""" List choices for the specified filter category. kwargs can be any of the same
|
|
|
|
kwargs in self.search() to help narrow down the choices to only those that
|
|
|
|
matter in your current context.
|
2014-12-29 03:21:58 +00:00
|
|
|
"""
|
2016-03-31 20:52:48 +00:00
|
|
|
if category in kwargs:
|
|
|
|
raise BadRequest('Cannot include kwarg equal to specified category: %s' % category)
|
2014-12-29 03:21:58 +00:00
|
|
|
args = {}
|
2016-03-31 20:52:48 +00:00
|
|
|
for subcategory, value in kwargs.items():
|
|
|
|
args[category] = self._cleanSearchFilter(subcategory, value)
|
|
|
|
if libtype is not None: args['type'] = utils.searchType(libtype)
|
|
|
|
query = '/library/sections/%s/%s%s' % (self.key, category, utils.joinArgs(args))
|
2016-03-31 22:36:54 +00:00
|
|
|
return utils.listItems(self.server, query, bytag=True)
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2016-03-31 20:52:48 +00:00
|
|
|
def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwargs):
|
|
|
|
""" Search the library. If there are many results, they will be fetched from the server
|
|
|
|
in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first <num>
|
|
|
|
results, it would be wise to set the maxresults option to that amount so this functions
|
|
|
|
doesn't iterate over all results on the server.
|
|
|
|
title: General string query to search for.
|
|
|
|
sort: column:dir; column can be any of {addedAt, originallyAvailableAt, lastViewedAt,
|
|
|
|
titleSort, rating, mediaHeight, duration}. dir can be asc or desc.
|
|
|
|
maxresults: Only return the specified number of results
|
|
|
|
libtype: Filter results to a spcifiec libtype {movie, show, episode, artist, album, track}
|
|
|
|
kwargs: Any of the available filters for the current library section. Partial string
|
|
|
|
matches allowed. Multiple matches OR together. All inputs will be compared with the
|
|
|
|
available options and a warning logged if the option does not appear valid.
|
|
|
|
'unwatched': Display or hide unwatched content (True, False). [all]
|
|
|
|
'duplicate': Display or hide duplicate items (True, False). [movie]
|
|
|
|
'actor': List of actors to search ([actor_or_id, ...]). [movie]
|
|
|
|
'collection': List of collections to search within ([collection_or_id, ...]). [all]
|
2016-04-07 05:39:04 +00:00
|
|
|
'contentRating': List of content ratings to search within ([rating_or_key, ...]). [movie,tv]
|
|
|
|
'country': List of countries to search within ([country_or_key, ...]). [movie,music]
|
2016-03-31 20:52:48 +00:00
|
|
|
'decade': List of decades to search within ([yyy0, ...]). [movie]
|
|
|
|
'director': List of directors to search ([director_or_id, ...]). [movie]
|
|
|
|
'genre': List Genres to search within ([genere_or_id, ...]). [all]
|
|
|
|
'network': List of TV networks to search within ([resolution_or_key, ...]). [tv]
|
|
|
|
'resolution': List of video resolutions to search within ([resolution_or_key, ...]). [movie]
|
|
|
|
'studio': List of studios to search within ([studio_or_key, ...]). [music]
|
|
|
|
'year': List of years to search within ([yyyy, ...]). [all]
|
|
|
|
"""
|
|
|
|
# Cleanup the core arguments
|
|
|
|
args = {}
|
|
|
|
for category, value in kwargs.items():
|
|
|
|
args[category] = self._cleanSearchFilter(category, value, libtype)
|
|
|
|
if title is not None: args['title'] = title
|
|
|
|
if sort is not None: args['sort'] = self._cleanSearchSort(sort)
|
|
|
|
if libtype is not None: args['type'] = utils.searchType(libtype)
|
|
|
|
# Iterate over the results
|
|
|
|
results, subresults = [], '_init'
|
|
|
|
args['X-Plex-Container-Start'] = 0
|
|
|
|
args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults)
|
|
|
|
while subresults and maxresults > len(results):
|
|
|
|
query = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args))
|
|
|
|
subresults = utils.listItems(self.server, query)
|
|
|
|
results += subresults[:maxresults-len(results)]
|
|
|
|
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
|
|
|
|
return results
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2016-03-31 20:52:48 +00:00
|
|
|
def _cleanSearchFilter(self, category, value, libtype=None):
|
2016-03-31 22:36:54 +00:00
|
|
|
# check a few things before we begin
|
2016-03-31 20:52:48 +00:00
|
|
|
if category not in self.ALLOWED_FILTERS:
|
|
|
|
raise BadRequest('Unknown filter category: %s' % category)
|
|
|
|
if category in self.BOOLEAN_FILTERS:
|
|
|
|
return '1' if value else '0'
|
|
|
|
if not isinstance(value, (list, tuple)):
|
|
|
|
value = [value]
|
2016-03-31 22:36:54 +00:00
|
|
|
# convert list of values to list of keys or ids
|
|
|
|
result = set()
|
2016-03-31 20:52:48 +00:00
|
|
|
choices = self.listChoices(category, libtype)
|
|
|
|
lookup = {c.title.lower():c.key for c in choices}
|
|
|
|
allowed = set(c.key for c in choices)
|
2016-03-31 22:36:54 +00:00
|
|
|
for item in value:
|
|
|
|
item = str(item.id if isinstance(item, MediaTag) else item).lower()
|
|
|
|
# find most logical choice(s) to use in url
|
|
|
|
if item in allowed: result.add(item); continue
|
|
|
|
if item in lookup: result.add(lookup[item]); continue
|
|
|
|
matches = [k for t,k in lookup.items() if item in t]
|
|
|
|
if matches: map(result.add, matches); continue
|
|
|
|
# nothing matched; use raw item value
|
|
|
|
log.warning('Filter value not listed, using raw item value: %s' % item)
|
|
|
|
result.add(item)
|
2016-03-31 20:52:48 +00:00
|
|
|
return ','.join(result)
|
|
|
|
|
|
|
|
def _cleanSearchSort(self, sort):
|
|
|
|
sort = '%s:asc' % sort if ':' not in sort else sort
|
|
|
|
scol, sdir = sort.lower().split(':')
|
|
|
|
lookup = {s.lower():s for s in self.ALLOWED_SORT}
|
|
|
|
if scol not in lookup:
|
|
|
|
raise BadRequest('Unknown sort column: %s' % scol)
|
|
|
|
if sdir not in ('asc', 'desc'):
|
|
|
|
raise BadRequest('Unknown sort dir: %s' % sdir)
|
|
|
|
return '%s:%s' % (lookup[scol], sdir)
|
|
|
|
|
2014-12-29 03:21:58 +00:00
|
|
|
|
|
|
|
class MovieSection(LibrarySection):
|
2016-03-31 20:52:48 +00:00
|
|
|
ALLOWED_FILTERS = ('unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating', 'collection',
|
|
|
|
'director', 'actor', 'country', 'studio', 'resolution')
|
|
|
|
ALLOWED_SORT = ('addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
|
|
|
|
'mediaHeight', 'duration')
|
2014-12-29 03:21:58 +00:00
|
|
|
TYPE = 'movie'
|
|
|
|
|
|
|
|
|
|
|
|
class ShowSection(LibrarySection):
|
2016-03-31 20:52:48 +00:00
|
|
|
ALLOWED_FILTERS = ('unwatched', 'year', 'genre', 'contentRating', 'network', 'collection')
|
|
|
|
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort', 'rating', 'unwatched')
|
2014-12-29 03:21:58 +00:00
|
|
|
TYPE = 'show'
|
2015-06-08 16:41:47 +00:00
|
|
|
|
2016-03-31 20:52:48 +00:00
|
|
|
def searchShows(self, **kwargs):
|
|
|
|
return self.search(libtype='show', **kwargs)
|
2014-12-29 03:21:58 +00:00
|
|
|
|
2016-03-31 20:52:48 +00:00
|
|
|
def searchEpisodes(self, **kwargs):
|
|
|
|
return self.search(libtype='episode', **kwargs)
|
2014-12-29 03:21:58 +00:00
|
|
|
|
|
|
|
|
2016-01-19 09:41:12 +00:00
|
|
|
class MusicSection(LibrarySection):
|
2016-03-31 20:52:48 +00:00
|
|
|
ALLOWED_FILTERS = ('genre', 'country', 'collection')
|
|
|
|
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
|
2016-01-19 09:41:12 +00:00
|
|
|
TYPE = 'artist'
|
2016-03-31 20:52:48 +00:00
|
|
|
|
2016-04-13 02:47:46 +00:00
|
|
|
def albums(self):
|
|
|
|
return utils.listItems(self.server, '/library/sections/%s/albums' % self.key)
|
|
|
|
|
2016-04-10 03:59:47 +00:00
|
|
|
def searchArtists(self, **kwargs):
|
2016-03-31 20:52:48 +00:00
|
|
|
return self.search(libtype='artist', **kwargs)
|
2016-01-19 09:41:12 +00:00
|
|
|
|
2016-04-10 03:59:47 +00:00
|
|
|
def searchAlbums(self, **kwargs):
|
2016-03-31 20:52:48 +00:00
|
|
|
return self.search(libtype='album', **kwargs)
|
|
|
|
|
|
|
|
def searchTracks(self, **kwargs):
|
|
|
|
return self.search(libtype='track', **kwargs)
|
2016-01-19 09:41:12 +00:00
|
|
|
|
|
|
|
|
2016-04-10 03:59:47 +00:00
|
|
|
class PhotoSection(LibrarySection):
|
|
|
|
ALLOWED_FILTERS = ()
|
|
|
|
ALLOWED_SORT = ()
|
|
|
|
TYPE = 'photo'
|
|
|
|
|
|
|
|
def searchAlbums(self, **kwargs):
|
|
|
|
return self.search(libtype='photo', **kwargs)
|
|
|
|
|
|
|
|
def searchPhotos(self, **kwargs):
|
|
|
|
return self.search(libtype='photo', **kwargs)
|
|
|
|
|
|
|
|
|
2016-03-31 20:52:48 +00:00
|
|
|
@utils.register_libtype
|
|
|
|
class FilterChoice(object):
|
|
|
|
TYPE = 'Directory'
|
2016-01-19 09:41:12 +00:00
|
|
|
|
2016-03-31 20:52:48 +00:00
|
|
|
def __init__(self, server, data, initpath):
|
|
|
|
self.server = server
|
|
|
|
self.initpath = initpath
|
|
|
|
self.fastKey = data.attrib.get('fastKey')
|
|
|
|
self.key = data.attrib.get('key')
|
2016-03-31 22:36:54 +00:00
|
|
|
self.thumb = data.attrib.get('thumb')
|
2016-03-31 20:52:48 +00:00
|
|
|
self.title = data.attrib.get('title')
|
2016-03-31 22:36:54 +00:00
|
|
|
self.type = data.attrib.get('type')
|
2016-01-19 09:41:12 +00:00
|
|
|
|
2016-03-31 20:52:48 +00:00
|
|
|
def __repr__(self):
|
|
|
|
title = self.title.replace(' ','.')[0:20]
|
|
|
|
return '<%s:%s:%s>' % (self.__class__.__name__, self.key, title)
|