Add TAG constant to PlexObjects; Better method to save and build PLEXOBJECTS; All objects in media.py are now registered and can be looked up; Remove __len__ on Library class (it was causing URL to load twice).

This commit is contained in:
Michael Shepanski 2017-02-12 21:55:55 -05:00
parent 3783f3c61b
commit 9b791b95e7
14 changed files with 262 additions and 233 deletions

View file

@ -28,8 +28,6 @@ class Audio(PlexPartialObject):
updatedAt (datatime): Datetime this item was updated.
viewCount (int): Count of times this item was accessed.
"""
TYPE = None
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
@ -55,7 +53,7 @@ class Audio(PlexPartialObject):
return self._server.url(self.thumb)
@utils.register_libtype
@utils.registerPlexObject
class Artist(Audio):
""" Represents a single audio artist.
@ -73,6 +71,7 @@ class Artist(Audio):
location (str): Filepath this artist is found on disk.
similar (list): List of :class:`~plexapi.media.Similar` artists.
"""
TAG = 'Directory'
TYPE = 'artist'
def _loadData(self, data):
@ -82,9 +81,9 @@ class Artist(Audio):
self.guid = data.attrib.get('guid')
self.key = self.key.replace('/children', '') # FIX_BUG_50
self.location = utils.findLocations(data, single=True)
self.countries = self._buildItems(data, media.Country, bytag=True)
self.genres = self._buildItems(data, media.Genre, bytag=True)
self.similar = self._buildItems(data, media.Similar, bytag=True)
self.countries = self.findItems(data, media.Country)
self.genres = self.findItems(data, media.Genre)
self.similar = self.findItems(data, media.Similar)
def album(self, title):
""" Returns the :class:`~plexapi.audio.Album` that matches the specified title.
@ -138,7 +137,7 @@ class Artist(Audio):
return filepaths
@utils.register_libtype
@utils.registerPlexObject
class Album(Audio):
""" Represents a single audio album.
@ -159,6 +158,7 @@ class Album(Audio):
studio (str): Studio that released this album.
year (int): Year this album was released.
"""
TAG = 'Directory'
TYPE = 'album'
def _loadData(self, data):
@ -173,7 +173,7 @@ class Album(Audio):
self.parentTitle = data.attrib.get('parentTitle')
self.studio = data.attrib.get('studio')
self.year = utils.cast(int, data.attrib.get('year'))
self.genres = self._buildItems(data, media.Genre, bytag=True)
self.genres = self.findItems(data, media.Genre)
def track(self, title):
""" Returns the :class:`~plexapi.audio.Track` that matches the specified title.
@ -216,7 +216,7 @@ class Album(Audio):
return filepaths
@utils.register_libtype
@utils.registerPlexObject
class Track(Audio, Playable):
""" Represents a single audio track.
@ -253,6 +253,7 @@ class Track(Audio, Playable):
transcodeSession (None): :class:`~plexapi.media.TranscodeSession` for playing
track (active sessions only).
"""
TAG = 'Track'
TYPE = 'track'
def _loadData(self, data):
@ -278,8 +279,8 @@ class Track(Audio, Playable):
self.ratingCount = utils.cast(int, data.attrib.get('ratingCount'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))
self.media = self._buildItems(data, media.Media, bytag=True)
self.moods = self._buildItems(data, media.Mood, bytag=True)
self.media = self.findItems(data, media.Media)
self.moods = self.findItems(data, media.Mood)
# data for active sessions and history
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey'))
self.username = utils.findUsername(data)
@ -298,8 +299,8 @@ class Track(Audio, Playable):
def album(self):
""" Return this track's :class:`~plexapi.audio.Album`. """
return self.fetchItems(self.parentKey)[0]
return self.fetchItem(self.parentKey)
def artist(self):
""" Return this track's :class:`~plexapi.audio.Artist`. """
return self.fetchItems(self.grandparentKey)[0]
return self.fetchItem(self.grandparentKey)

View file

@ -2,8 +2,7 @@
import re
from plexapi import log, utils
from plexapi.compat import urlencode
from plexapi.exceptions import BadRequest, NotFound
from plexapi.exceptions import UnknownType, Unsupported
from plexapi.exceptions import NotFound, UnknownType, Unsupported
OPERATORS = {
'exact': lambda v,q: v == q,
@ -19,7 +18,7 @@ OPERATORS = {
'istartswith': lambda v,q: v.lower().startswith(q),
'endswith': lambda v,q: v.endswith(q),
'iendswith': lambda v,q: v.lower().endswith(q),
'ismissing': None, # special case in _checkAttrs
'exists': lambda v,q: v is not None if q else v is None,
'regex': lambda v,q: re.match(q, v),
'iregex': lambda v,q: re.match(q, v, flags=re.IGNORECASE),
}
@ -27,73 +26,58 @@ OPERATORS = {
class PlexObject(object):
""" Base class for all Plex objects.
TODO: Finish documenting this.
"""
key = None
def __init__(self, root, data, initpath=None):
self._server = root # Root MyPlexAccount or PlexServer
self._data = data # XML data needed to build object
self._initpath = initpath or self.key # Request path used to fetch data
Parameters:
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
data (ElementTree): Response from PlexServer used to build this object (optional).
initpath (str): Relative path requested when retrieving specified `data` (optional).
"""
TAG = None # xml element tag
TYPE = None # xml element type
key = None # plex relative url
def __init__(self, server, data, initpath=None):
self._server = server
self._data = data
self._initpath = initpath or self.key
self._loadData(data)
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
self.__class__.__name__,
self.__firstattr('_baseurl', 'key', 'id', 'playQueueID', 'uri'),
self.__firstattr('title', 'name', 'username', 'product', 'tag')
utils.firstAttr(self, '_baseurl', 'key', 'id', 'playQueueID', 'uri'),
utils.firstAttr(self, 'title', 'name', 'username', 'product', 'tag')
] if p])
def __setattr__(self, attr, value):
# dont overwrite an attr with None unless its a private variable
if value is not None or attr.startswith('_') or attr not in self.__dict__:
self.__dict__[attr] = value
def __firstattr(self, *attrs):
for attr in attrs:
value = self.__dict__.get(attr)
if value:
value = str(value).replace(' ','-')
value = value.replace('/library/metadata/','')
value = value.replace('/children','')
return value[:20]
def _buildItem(self, elem, cls=None, initpath=None, bytag=False):
""" Factory function to build objects based on registered LIBRARY_TYPES. """
def _buildItem(self, elem, cls=None, initpath=None):
""" Factory function to build objects based on registered PLEXOBJECTS. """
# cls is specified, build the object and return
initpath = initpath or self._initpath
libtype = elem.tag if bytag else elem.attrib.get('type')
if libtype == 'photo' and elem.tag == 'Directory':
libtype = 'photoalbum'
if cls and libtype == cls.TYPE:
if cls is not None:
return cls(self._server, elem, initpath)
if libtype in utils.LIBRARY_TYPES:
cls = utils.LIBRARY_TYPES[libtype]
return cls(self._server, elem, initpath)
raise UnknownType("Unknown library type <%s type='%s'../>" % (elem.tag, libtype))
# cls is not specified, try looking it up in PLEXOBJECTS
etype = elem.attrib.get('type', elem.attrib.get('streamType'))
ehash = '%s.%s' % (elem.tag, etype) if etype else elem.tag
ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag))
if ecls is not None:
return ecls(self._server, elem, initpath)
raise UnknownType("Unknown library type <%s type='%s'../>" % (elem.tag, etype))
def _buildItemOrNone(self, elem, cls=None, initpath=None, bytag=False):
def _buildItemOrNone(self, elem, cls=None, initpath=None):
""" Calls :func:`~plexapi.base.PlexObject._buildItem()` but returns
None if elem is an unknown type.
"""
try:
return self._buildItem(elem, cls, initpath, bytag)
return self._buildItem(elem, cls, initpath)
except UnknownType:
return None
def _buildItems(self, data, cls=None, initpath=None, bytag=False):
""" Build and return a list of items (optionally filtered by tag).
Parameters:
data (ElementTree): XML data to search for items.
cls (:class:`plexapi.base.PlexObject`): Optionally specify the PlexObject
to be built. If not specified _buildItem will be called and the best
guess item will be built.
"""
items = []
for elem in data:
items.append(self._buildItemOrNone(elem, cls, initpath, bytag))
return [item for item in items if item]
def fetchItem(self, key, cls=None, bytag=False, tag=None, **kwargs):
def fetchItem(self, ekey, cls=None, **kwargs):
""" Load the specified key to find and build the first item with the
specified tag and attrs. If no tag or attrs are specified then
the first item in the result set is returned.
@ -105,10 +89,8 @@ class PlexObject(object):
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
items to be fetched, passing this in will help the parser ensure
it only returns those items. By default we convert the xml elements
to the best guess PlexObjects based on the type attr or tag.
bytag (bool): Setting this to True tells the build-items function to guess
the PlexObject to build from the tag (instead of the type attr).
tag (str): Only fetch items with the specified tag.
with the best guess PlexObjects based on tag and type attrs.
etag (str): Only fetch items with the specified tag.
**kwargs (dict): Optionally add attribute filters on the items to fetch. For
example, passing in viewCount=0 will only return matching items. Filtering
is done before the Python objects are built to help keep things speedy.
@ -121,42 +103,57 @@ class PlexObject(object):
passing in viewCount__gte=0 will return all items where viewCount >= 0.
Available operations include:
* __exact: Value matches specified arg.
* __iexact: Case insensative value matches specified arg.
* __contains: Value contains specified arg.
* __icontains: Case insensative value contains specified arg.
* __in: Value is in a specified list or tuple.
* __endswith: Value ends with specified arg.
* __exact: Value matches specified arg.
* __exists (bool): Value is or is not present in the attrs.
* __gt: Value is greater than specified arg.
* __gte: Value is greater than or equal to specified arg.
* __icontains: Case insensative value contains specified arg.
* __iendswith: Case insensative value ends with specified arg.
* __iexact: Case insensative value matches specified arg.
* __in: Value is in a specified list or tuple.
* __iregex: Case insensative value matches the specified regular expression.
* __istartswith: Case insensative value starts with specified arg.
* __lt: Value is less than specified arg.
* __lte: Value is less than or equal to specified arg.
* __startswith: Value starts with specified arg.
* __istartswith: Case insensative value starts with specified arg.
* __endswith: Value ends with specified arg.
* __iendswith: Case insensative value ends with specified arg.
* __ismissing (bool): Value is or is not present in the attrs.
* __regex: Value matches the specified regular expression.
* __iregex: Case insensative value matches the specified regular expression.
* __startswith: Value starts with specified arg.
"""
if isinstance(key, int):
key = '/library/metadata/%s' % key
for elem in self._server.query(key):
if tag and elem.tag != tag or not self._checkAttrs(elem, **kwargs):
continue
return self._buildItem(elem, cls, key, bytag)
raise NotFound('Unable to find elem: tag=%s, attrs=%s' % (tag, kwargs))
if isinstance(ekey, int):
ekey = '/library/metadata/%s' % ekey
for elem in self._server.query(ekey):
if self._checkAttrs(elem, **kwargs):
return self._buildItem(elem, cls, ekey)
clsname = cls.__name__ if cls else 'None'
raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs))
def fetchItems(self, key, cls=None, bytag=False, tag=None, **kwargs):
def fetchItems(self, ekey, cls=None, **kwargs):
""" Load the specified key to find and build all items with the specified tag
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used.
"""
data = self._server.query(ekey)
return self.findItems(data, cls, ekey, **kwargs)
def findItems(self, data, cls=None, initpath=None, **kwargs):
""" Load the specified data to find and build all items with the specified tag
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used.
"""
# filter on cls attrs if specified
if cls and cls.TAG and 'tag' not in kwargs:
kwargs['etag'] = cls.TAG
if cls and cls.TYPE and 'type' not in kwargs:
kwargs['type'] = cls.TYPE
# loop through all data elements to find matches
items = []
for elem in self._server.query(key):
if tag and elem.tag != tag or not self._checkAttrs(elem, **kwargs):
continue
items.append(self._buildItemOrNone(elem, cls, key, bytag))
return [item for item in items if item]
for elem in data:
if self._checkAttrs(elem, **kwargs):
item = self._buildItemOrNone(elem, cls, initpath)
if item is not None:
items.append(item)
return items
def reload(self, safe=False):
""" Reload the data for this object from self.key. """
@ -173,28 +170,20 @@ class PlexObject(object):
for attr, query in kwargs.items():
attr, op, operator = self._getAttrOperator(attr)
values = self._getAttrValue(elem, attr)
# special case ismissing operator
if op == 'ismissing':
if query not in (True, False):
raise BadRequest('Value when using __ismissing must be in (True, False).')
if (query is True and values) or (query is False and not values):
return False
# special case query in (None,0,'') to include missing attr
if op == 'exact' and query in (None, 0, '') and not values:
# special case query in (None, 0, '') to include missing attr
if op == 'exact' and not values and query in (None, 0, ''):
return True
# return if attr were looking for is missing
attrsFound[attr] = False
for value in values:
if isinstance(query, int): value = int(value)
if isinstance(query, float): value = float(value)
if isinstance(query, bool): value = bool(int(value))
value = self._castAttrValue(op, query, value)
if operator(value, query):
attrsFound[attr] = True
break
#log.debug('Checking %s for %s found: %s', elem.tag, kwargs, attrsFound)
return all(attrsFound.values())
def _getAttrOperator(self, attr):
attr = attr.lstrip('_')
for op, operator in OPERATORS.items():
if attr.endswith('__%s' % op):
attr = attr.rsplit('__', 1)[0]
@ -212,12 +201,28 @@ class PlexObject(object):
for child in [c for c in elem if c.tag.lower() == attr.lower()]:
results += self._getAttrValue(child, attrstr, results)
return [r for r in results if r is not None]
# check were looking for the tag
if attr.lower() == 'etag':
return [elem.tag]
# loop through attrs so we can perform case-insensative match
for _attr, value in elem.attrib.items():
if attr.lower() == _attr.lower():
return [value]
return []
def _castAttrValue(self, op, query, value):
if op == 'exists':
return value
if isinstance(query, bool):
return bool(int(value))
if isinstance(query, int) and '.' in value:
return float(value)
if isinstance(query, int):
return int(value)
if isinstance(query, float):
return float(value)
return value
def _loadData(self, data):
raise NotImplementedError('Abstract method not implemented.')

View file

@ -44,6 +44,7 @@ class PlexClient(PlexObject):
_proxyThroughServer (bool): Set to True after calling
:func:`~plexapi.client.PlexClient.proxyThroughServer()` (default False).
"""
TAG = 'Player'
key = '/resources'
def __init__(self, baseurl, token=None, session=None, server=None, data=None):

View file

@ -27,38 +27,29 @@ class Library(PlexObject):
self.title1 = data.attrib.get('title1')
self.title2 = data.attrib.get('title2')
def __len__(self):
return len(self.sections())
def sections(self):
""" Returns a list of all media sections in this library. Library sections may be any of
:class:`~plexapi.library.MovieSection`, :class:`~plexapi.library.ShowSection`,
:class:`~plexapi.library.MusicSection`, :class:`~plexapi.library.PhotoSection`.
"""
SECTION_TYPES = {MovieSection.TYPE:MovieSection, ShowSection.TYPE:ShowSection,
MusicSection.TYPE: MusicSection, PhotoSection.TYPE: PhotoSection}
items = []
key = '/library/sections'
sections = []
for elem in self._server.query(key):
stype = elem.attrib['type']
if stype in SECTION_TYPES:
cls = SECTION_TYPES[stype]
section = cls(self._server, elem, key)
self._sectionsByID[section.key] = section
items.append(section)
return items
for cls in (MovieSection, ShowSection, MusicSection, PhotoSection):
if elem.attrib.get('type') == cls.TYPE:
section = cls(self._server, elem, key)
self._sectionsByID[section.key] = section
sections.append(section)
return sections
def section(self, title=None):
""" Returns the :class:`~plexapi.library.LibrarySection` that matches the specified title.
Parameters:
title (str): Title of the section to return.
Raises:
:class:`~plexapi.exceptions.NotFound`: Invalid library section title.
"""
for section in self.sections():
if section.title == title:
if section.title.lower() == title.lower():
return section
raise NotFound('Invalid library section: %s' % title)
@ -76,7 +67,11 @@ class Library(PlexObject):
""" Returns a list of all media from all library sections.
This may be a very large dataset to retrieve.
"""
return [item for section in self.sections() for item in section.all(**kwargs)]
items = []
for section in self.sections():
for item in section.all(**kwargs):
items.append(item)
return items
def onDeck(self):
""" Returns a list of all media items on deck. """
@ -185,10 +180,6 @@ class LibrarySection(PlexObject):
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.uuid = data.attrib.get('uuid')
def __repr__(self):
return '<%s>' % ':'.join([p for p in [self.__class__.__name__,
self.key, self.librarySectionTitle] if p])
def get(self, title):
""" Returns the media item with the specified title.
@ -259,7 +250,7 @@ class LibrarySection(PlexObject):
if libtype is not None:
args['type'] = utils.searchType(libtype)
key = '/library/sections/%s/%s%s' % (self.key, category, utils.joinArgs(args))
return self.fetchItems(key, bytag=True)
return self.fetchItems(key, cls=FilterChoice)
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
@ -360,12 +351,14 @@ class MovieSection(LibrarySection):
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt',
'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
'mediaHeight', 'duration')
TAG (str): 'Directory'
TYPE (str): 'movie'
"""
ALLOWED_FILTERS = ('unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating',
'collection', 'director', 'actor', 'country', 'studio', 'resolution')
ALLOWED_SORT = ('addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating',
'mediaHeight', 'duration')
TAG = 'Directory'
TYPE = 'movie'
@ -377,11 +370,13 @@ class ShowSection(LibrarySection):
'year', 'genre', 'contentRating', 'network', 'collection')
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt', 'lastViewedAt',
'originallyAvailableAt', 'titleSort', 'rating', 'unwatched')
TAG (str): 'Directory'
TYPE (str): 'show'
"""
ALLOWED_FILTERS = ('unwatched', 'year', 'genre', 'contentRating', 'network', 'collection')
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort',
'rating', 'unwatched')
TAG = 'Directory'
TYPE = 'show'
def searchShows(self, **kwargs):
@ -409,10 +404,12 @@ class MusicSection(LibrarySection):
'country', 'collection')
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt',
'lastViewedAt', 'viewCount', 'titleSort')
TAG (str): 'Directory'
TYPE (str): 'artist'
"""
ALLOWED_FILTERS = ('genre', 'country', 'collection')
ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort')
TAG = 'Directory'
TYPE = 'artist'
def albums(self):
@ -437,12 +434,15 @@ class PhotoSection(LibrarySection):
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos.
Attributes:
ALLOWED_FILTERS (list<str>): List of allowed search filters. <NONE>
ALLOWED_SORT (list<str>): List of allowed sorting keys. <NONE>
ALLOWED_FILTERS (list<str>): List of allowed search filters. ('all', 'iso',
'make', 'lens', 'aperture', 'exposure')
ALLOWED_SORT (list<str>): List of allowed sorting keys. ('addedAt')
TAG (str): 'Directory'
TYPE (str): 'photo'
"""
ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure')
ALLOWED_SORT = ('addedAt')
ALLOWED_SORT = ('addedAt',)
TAG = 'Directory'
TYPE = 'photo'
def searchAlbums(self, title, **kwargs):
@ -456,7 +456,6 @@ class PhotoSection(LibrarySection):
return self.fetchItems(key, title=title)
@utils.register_libtype
class FilterChoice(PlexObject):
""" Represents a single filter choice. These objects are gathered when using filters
while searching for library items and is the object returned in the result set of
@ -472,7 +471,7 @@ class FilterChoice(PlexObject):
title (str): Human readable name for this filter option.
type (str): Filter type (genre, contentRating, etc).
"""
TYPE = 'Directory'
TAG = 'Directory'
def _loadData(self, data):
self._data = data
@ -483,10 +482,10 @@ class FilterChoice(PlexObject):
self.type = data.attrib.get('type')
@utils.register_libtype
@utils.registerPlexObject
class Hub(PlexObject):
FILTERTYPES = {'genre':Genre, 'director':Director, 'actor':Role}
TYPE = 'Hub'
TAG = 'Hub'
def _loadData(self, data):
self._data = data
@ -494,13 +493,7 @@ class Hub(PlexObject):
self.size = utils.cast(int, data.attrib.get('size'))
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
self.items = self._buildItems(data)
self.items = self.findItems(data)
def __len__(self):
return self.size
def _buildItems(self, data):
if self.type in self.FILTERTYPES:
cls = self.FILTERTYPES[self.type]
return [cls(self._server, elem, self._initpath) for elem in data]
return super(Hub, self)._buildItems(data)

View file

@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
from plexapi import utils
from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest
from plexapi.utils import cast
@utils.registerPlexObject
class Media(PlexObject):
""" Container object for all MediaPart objects. Provides useful data about the
video this media belong to such as video framerate, resolution, etc.
@ -34,7 +36,7 @@ class Media(PlexObject):
width (int): Width of the video in pixels (ex: 608).
parts (list<:class:`~plexapi.media.MediaPart`>): List of MediaParts in this video.
"""
TYPE = 'Media'
TAG = 'Media'
def _loadData(self, data):
self._data = data
@ -52,9 +54,10 @@ class Media(PlexObject):
self.videoFrameRate = data.attrib.get('videoFrameRate')
self.videoResolution = data.attrib.get('videoResolution')
self.width = cast(int, data.attrib.get('width'))
self.parts = self._buildItems(data, MediaPart, bytag=True)
self.parts = self.findItems(data, MediaPart)
@utils.registerPlexObject
class MediaPart(PlexObject):
""" Represents a single media part (often a single file) for the media this belongs to.
@ -70,7 +73,7 @@ class MediaPart(PlexObject):
size (int): Size of this file in bytes (ex: 733884416).
streams (list<:class:`~plexapi.media.MediaPartStream`>): List of streams in this media part.
"""
TYPE = 'Part'
TAG = 'Part'
def _loadData(self, data):
self._data = data
@ -121,8 +124,6 @@ class MediaPartStream(PlexObject):
2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`).
type (int): Alias for streamType.
"""
TYPE = None
STREAMTYPE = None
def _loadData(self, data):
self._data = data
@ -145,6 +146,7 @@ class MediaPartStream(PlexObject):
return cls(server, data, initpath)
@utils.registerPlexObject
class VideoStream(MediaPartStream):
""" Respresents a video stream within a :class:`~plexapi.media.MediaPart`.
@ -166,7 +168,7 @@ class VideoStream(MediaPartStream):
title (str): Title of this video stream.
width (int): Width of video stream.
"""
TYPE = 'videostream'
TAG = 'Stream'
STREAMTYPE = 1
def _loadData(self, data):
@ -189,6 +191,7 @@ class VideoStream(MediaPartStream):
self.width = cast(int, data.attrib.get('width'))
@utils.registerPlexObject
class AudioStream(MediaPartStream):
""" Respresents a audio stream within a :class:`~plexapi.media.MediaPart`.
@ -203,7 +206,7 @@ class AudioStream(MediaPartStream):
samplingRate (int): Sampling rate (ex: xxx)
title (str): Title of this audio stream.
"""
TYPE = 'audiostream'
TAG = 'Stream'
STREAMTYPE = 2
def _loadData(self, data):
@ -219,6 +222,7 @@ class AudioStream(MediaPartStream):
self.title = data.attrib.get('title')
@utils.registerPlexObject
class SubtitleStream(MediaPartStream):
""" Respresents a audio stream within a :class:`~plexapi.media.MediaPart`.
@ -227,7 +231,7 @@ class SubtitleStream(MediaPartStream):
key (str): Key of this subtitle stream (ex: /library/streams/212284).
title (str): Title of this subtitle stream.
"""
TYPE = 'subtitlestream'
TAG = 'Stream'
STREAMTYPE = 3
def _loadData(self, data):
@ -237,11 +241,12 @@ class SubtitleStream(MediaPartStream):
self.title = data.attrib.get('title')
@utils.registerPlexObject
class TranscodeSession(PlexObject):
""" Represents a current transcode session.
TODO: Document this.
"""
TYPE = 'TranscodeSession'
TAG = 'TranscodeSession'
def _loadData(self, data):
self._data = data
@ -289,8 +294,6 @@ class MediaTag(PlexObject):
* tagType (int): Tag type ID.
* thumb (str): URL to thumbnail image.
"""
TYPE = None
def _loadData(self, data):
self._data = data
self.id = cast(int, data.attrib.get('id'))
@ -313,45 +316,54 @@ class MediaTag(PlexObject):
return self.fetchItems(self.key)
@utils.registerPlexObject
class Collection(MediaTag):
TYPE = 'Collection'
TAG = 'Collection'
FILTER = 'collection'
@utils.registerPlexObject
class Country(MediaTag):
TYPE = 'Country'
TAG = 'Country'
FILTER = 'country'
@utils.registerPlexObject
class Director(MediaTag):
TYPE = 'Director'
TAG = 'Director'
FILTER = 'director'
@utils.registerPlexObject
class Genre(MediaTag):
TYPE = 'Genre'
TAG = 'Genre'
FILTER = 'genre'
@utils.registerPlexObject
class Mood(MediaTag):
TYPE = 'Mood'
TAG = 'Mood'
FILTER = 'mood'
@utils.registerPlexObject
class Producer(MediaTag):
TYPE = 'Producer'
TAG = 'Producer'
FILTER = 'producer'
@utils.registerPlexObject
class Role(MediaTag):
TYPE = 'Role'
TAG = 'Role'
FILTER = 'role'
@utils.registerPlexObject
class Similar(MediaTag):
TYPE = 'Similar'
TAG = 'Similar'
FILTER = 'similar'
@utils.registerPlexObject
class Writer(MediaTag):
TYPE = 'Writer'
TAG = 'Writer'
FILTER = 'writer'
@utils.registerPlexObject
class Field(PlexObject):
TYPE = 'Field'
TAG = 'Field'
def _loadData(self, data):
self._data = data

View file

@ -152,6 +152,7 @@ class MyPlexAccount(PlexObject):
return [MyPlexUser(self, elem) for elem in data]
@utils.registerPlexObject
class MyPlexUser(PlexObject):
""" This object represents non-signed in users such as friends and linked
accounts. NOTE: This should not be confused with the :class:`~myplex.MyPlexAccount`
@ -177,6 +178,7 @@ class MyPlexUser(PlexObject):
title (str): Seems to be an aliad for username
username (str): User's username
"""
TAG = 'User'
key = 'https://plex.tv/api/users/'
def _loadData(self, data):
@ -200,6 +202,7 @@ class MyPlexUser(PlexObject):
self.username = data.attrib.get('username')
#@utils.registerPlexObject
class MyPlexResource(PlexObject):
""" This object represents resources connected to your Plex server that can provide
content such as Plex Media Servers, iPhone or Android clients, etc. The raw xml
@ -225,6 +228,7 @@ class MyPlexResource(PlexObject):
player, pubsub-player, etc.)
synced (bool): Unknown (possibly True if the resource has synced content?)
"""
TAG = 'Device'
key = 'https://plex.tv/api/resources?includeHttps=1'
def _loadData(self, data):
@ -246,7 +250,7 @@ class MyPlexResource(PlexObject):
self.home = utils.cast(bool, data.attrib.get('home'))
self.synced = utils.cast(bool, data.attrib.get('synced'))
self.presence = utils.cast(bool, data.attrib.get('presence'))
self.connections = self._buildItems(data, ResourceConnection, bytag=True)
self.connections = self.findItems(data, ResourceConnection)
def connect(self, ssl=None, safe=False):
""" Returns a new :class:`~server.PlexServer` object. Often times there is more than
@ -300,6 +304,7 @@ class MyPlexResource(PlexObject):
results[i] = (url, self.accessToken, None)
@utils.registerPlexObject
class ResourceConnection(PlexObject):
""" Represents a Resource Connection object found within the
:class:`~myplex.MyPlexResource` objects.
@ -312,7 +317,7 @@ class ResourceConnection(PlexObject):
protocol (str): HTTP or HTTPS
uri (str): External address
"""
TYPE = 'Connection'
TAG = 'Connection'
def _loadData(self, data):
self._data = data
@ -324,6 +329,7 @@ class ResourceConnection(PlexObject):
self.httpuri = 'http://%s:%s' % (self.address, self.port)
#@utils.registerPlexObject
class MyPlexDevice(PlexObject):
""" This object represents resources connected to your Plex server that provide
playback ability from your Plex Server, iPhone or Android clients, Plex Web,
@ -350,6 +356,7 @@ class MyPlexDevice(PlexObject):
vendor (str): Device vendor (ubuntu, etc).
version (str): Unknown (1, 2, 1.3.3.3148-b38628e, 1.3.15, etc.)
"""
TAG = 'Device'
key = 'https://plex.tv/devices.xml'
def _loadData(self, data):

View file

@ -9,6 +9,10 @@ class PlexNotifier(threading.Thread):
notifications. These often include messages from Plex about media scans
as well as updates to currently running Transcode Sessions.
Parameters:
server (:class:`~plexapi.server.PlexServer`): PlexServer this notifier is connected to.
callback (func): Callback function to call on recieved messages.
NOTE: You need websocket-client installed in order to use this feature.
>> pip install websocket-client
"""
@ -21,9 +25,12 @@ class PlexNotifier(threading.Thread):
super(PlexNotifier, self).__init__()
def run(self):
""" Starts the PlexNotifier thread. This function should not be called
directly, instead use :func:`~plexapi.server.PlexServer.startNotifier`.
"""
# try importing websocket-client package
try:
import websocket # only require when needed
import websocket
except:
raise Unsupported('Websocket-client package is required to use this feature.')
# create the websocket connection
@ -35,6 +42,7 @@ class PlexNotifier(threading.Thread):
self._ws.run_forever()
def stop(self):
""" Stop the PlexNotifier thread. """
log.info('Stopping PlexNotifier.')
self._ws.close()

View file

@ -4,7 +4,7 @@ from plexapi.base import PlexPartialObject
from plexapi.exceptions import NotFound
@utils.register_libtype
@utils.registerPlexObject
class Photoalbum(PlexPartialObject):
""" Represents a photoalbum (collection of photos).
@ -29,7 +29,8 @@ class Photoalbum(PlexPartialObject):
type (str): Unknown
updatedAt (datatime): Datetime this item was updated.
"""
TYPE = 'photoalbum'
TAG = 'Directory'
TYPE = 'photo'
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
@ -61,7 +62,7 @@ class Photoalbum(PlexPartialObject):
raise NotFound('Unable to find photo: %s' % title)
@utils.register_libtype
@utils.registerPlexObject
class Photo(PlexPartialObject):
""" Represents a single photo.
@ -87,6 +88,7 @@ class Photo(PlexPartialObject):
updatedAt (datatime): Datetime this item was updated.
year (int): Year this photo was taken.
"""
TAG = 'Photo'
TYPE = 'photo'
def _loadData(self, data):
@ -106,7 +108,7 @@ class Photo(PlexPartialObject):
self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.year = utils.cast(int, data.attrib.get('year'))
self.media = self._buildItems(data, media.Media)
self.media = self.findItems(data, media.Media)
def photoalbum(self):
""" Return this photo's :class:`~plexapi.photo.Photoalbum`. """

View file

@ -6,8 +6,9 @@ from plexapi.playqueue import PlayQueue
from plexapi.utils import cast, toDatetime
@utils.register_libtype
@utils.registerPlexObject
class Playlist(PlexPartialObject, Playable):
TAG = 'Playlist'
TYPE = 'playlist'
def _loadData(self, data):

View file

@ -30,7 +30,7 @@ class PlayQueue(PlexObject):
self.playQueueSelectedItemOffset = data.attrib.get('playQueueSelectedItemOffset')
self.playQueueTotalCount = data.attrib.get('playQueueTotalCount')
self.playQueueVersion = data.attrib.get('playQueueVersion')
self.items = self._buildItems(data)
self.items = self.findItems(data)
@classmethod
def create(cls, server, item, shuffle=0, repeat=0, includeChapters=1, includeRelated=1):

View file

@ -7,13 +7,15 @@ from plexapi.base import PlexObject
from plexapi.client import PlexClient
from plexapi.compat import ElementTree, urlencode
from plexapi.exceptions import BadRequest, NotFound
from plexapi.library import Library
from plexapi.library import Library, Hub
from plexapi.notify import PlexNotifier
from plexapi.playlist import Playlist
from plexapi.playqueue import PlayQueue
from plexapi.utils import cast
# We import media to populate utils.LIBRARY_TYPES
from plexapi import audio, video, photo, playlist as _pl
# Need these imports to populate utils.PLEXOBJECTS
from plexapi import (audio as _audio, video as _video,
photo as _photo, media as _media, playlist as _playlist)
class PlexServer(PlexObject):
@ -243,13 +245,6 @@ class PlexServer(PlexObject):
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data else None
def url(self, key):
""" Build a URL string with proper token argument. """
if self._token:
delim = '&' if '?' in key else '?'
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
return '%s%s' % (self._baseurl, key)
def search(self, query, mediatype=None, limit=None):
""" Returns a list of media items or filter categories from the resulting
`Hub Search <https://www.plex.tv/blog/seek-plex-shall-find-leveling-web-app/>`_
@ -274,7 +269,7 @@ class PlexServer(PlexObject):
if limit:
params['limit'] = limit
key = '/hubs/search?%s' % urlencode(params)
for hub in self.fetchItems(key, bytag=True):
for hub in self.fetchItems(key, Hub):
results += hub.items
return results
@ -311,12 +306,18 @@ class PlexServer(PlexObject):
opacity (int): Opacity of the resulting image (possibly deprecated).
saturation (int): Saturating of the resulting image.
"""
# TODO: Does this function really belong here?
if media:
transcode_url = '/photo/:/transcode?height=%s&width=%s&opacity=%s&saturation=%s&url=%s' % (
height, width, opacity, saturation, media)
return self.url(transcode_url)
def url(self, key):
""" Build a URL string with proper token argument. """
if self._token:
delim = '&' if '?' in key else '?'
return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token)
return '%s%s' % (self._baseurl, key)
class Account(PlexObject):
""" Contains the locally cached MyPlex account information. The properties provided don't

View file

@ -9,7 +9,7 @@ from plexapi.exceptions import NotFound
# Library Types - Populated at runtime
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4,
'artist': 8, 'album': 9, 'track': 10, 'photo': 14}
LIBRARY_TYPES = {}
PLEXOBJECTS = {}
class SecretsFilter(logging.Filter):
@ -30,12 +30,17 @@ class SecretsFilter(logging.Filter):
return True
def register_libtype(cls):
def registerPlexObject(cls):
""" Registry of library types we may come across when parsing XML. This allows us to
define a few helper functions to dynamically convery the XML into objects. See
buildItem() below for an example.
"""
LIBRARY_TYPES[cls.TYPE] = cls
etype = getattr(cls, 'STREAMTYPE', cls.TYPE)
ehash = '%s.%s' % (cls.TAG, etype) if etype else cls.TAG
if ehash in PLEXOBJECTS:
raise Exception('Ambiguous PlexObject definition %s(tag=%s, type=%s) with %s' %
(cls.__name__, cls.TAG, etype, PLEXOBJECTS[ehash].__name__))
PLEXOBJECTS[ehash] = cls
return cls
@ -91,22 +96,6 @@ def findPlayer(server, data):
return None
def findStreams(media, streamtype):
""" Returns a list of streams (str) found in media that match the specified streamtype.
Parameters:
media (:class:`~plexapi.utils.Playable`): Item to search for streams (show, movie, episode).
streamtype (str): Streamtype to return (videostream, audiostream, subtitlestream).
"""
streams = []
for mediaitem in media:
for part in mediaitem.parts:
for stream in part.streams:
if stream.TYPE == streamtype:
streams.append(stream)
return streams
def findTranscodeSession(server, data):
""" Returns a :class:`~plexapi.media.TranscodeSession` object if found within the specified
XML data.
@ -135,6 +124,18 @@ def findUsername(data):
return None
def firstAttr(elem, *attrs):
""" Return the first attribute in attrs that is not None. """
for attr in attrs:
value = elem.__dict__.get(attr)
if value is not None:
value = str(value).replace(' ','-')
if attr == 'key':
value = value.replace('/library/metadata/','')
value = value.replace('/children','')
return value[:20]
def getattributeOrNone(obj, self, attr):
try:
return super(obj, self).__getattribute__(attr)

View file

@ -24,8 +24,6 @@ class Video(PlexPartialObject):
updatedAt (datatime): Datetime this item was updated.
viewCount (int): Count of times this item was accessed.
"""
TYPE = None
def _loadData(self, data):
self._data = data
self.listType = 'video'
@ -61,7 +59,7 @@ class Video(PlexPartialObject):
self.reload()
@utils.register_libtype
@utils.registerPlexObject
class Movie(Video, Playable):
""" Represents a single Movie.
@ -83,16 +81,10 @@ class Movie(Video, Playable):
userRating (float): User rating (2.0; 8.0).
viewOffset (int): View offset in milliseconds.
year (int): Year movie was released.
collections
countries
directors
fields
genres
media
producers
roles
writers
# TODO: Finish documenting plexapi.video.Movie
"""
TAG = 'Video'
TYPE = 'movie'
def _loadData(self, data):
@ -116,15 +108,15 @@ class Movie(Video, Playable):
self.userRating = utils.cast(float, data.attrib.get('userRating'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))
self.collections = self._buildItems(data, media.Collection)
self.countries = self._buildItems(data, media.Country, bytag=True)
self.directors = self._buildItems(data, media.Director, bytag=True)
self.fields = self._buildItems(data, media.Field, bytag=True)
self.genres = self._buildItems(data, media.Genre, bytag=True)
self.media = self._buildItems(data, media.Media, bytag=True)
self.producers = self._buildItems(data, media.Producer, bytag=True)
self.roles = self._buildItems(data, media.Role, bytag=True)
self.writers = self._buildItems(data, media.Writer, bytag=True)
self.collections = self.findItems(data, media.Collection)
self.countries = self.findItems(data, media.Country)
self.directors = self.findItems(data, media.Director)
self.fields = self.findItems(data, media.Field)
self.genres = self.findItems(data, media.Genre)
self.media = self.findItems(data, media.Media)
self.producers = self.findItems(data, media.Producer)
self.roles = self.findItems(data, media.Role)
self.writers = self.findItems(data, media.Writer)
@property
def actors(self):
@ -163,8 +155,9 @@ class Movie(Video, Playable):
return downloaded
@utils.register_libtype
@utils.registerPlexObject
class Show(Video):
TAG = 'Directory'
TYPE = 'show'
def _loadData(self, data):
@ -187,8 +180,8 @@ class Show(Video):
self.theme = data.attrib.get('theme')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year'))
self.genres = self._buildItems(data, media.Genre, bytag=True)
self.roles = self._buildItems(data, media.Role, bytag=True)
self.genres = self.findItems(data, media.Genre)
self.roles = self.findItems(data, media.Role)
@property
def actors(self):
@ -201,7 +194,7 @@ class Show(Video):
def seasons(self, **kwargs):
"""Returns a list of Season."""
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key, type=Season.TYPE, **kwargs)
return self.fetchItems(key, **kwargs)
def season(self, title=None):
""" Returns the season with the specified title or number.
@ -212,7 +205,7 @@ class Show(Video):
if isinstance(title, int):
title = 'Season %s' % title
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItem(key, tag='Directory', title__iexact=title)
return self.fetchItem(key, etag='Directory', title__iexact=title)
def episodes(self, **kwargs):
""" Returs a list of Episode """
@ -278,8 +271,9 @@ class Show(Video):
return downloaded
@utils.register_libtype
@utils.registerPlexObject
class Season(Video):
TAG = 'Directory'
TYPE = 'season'
def _loadData(self, data):
@ -316,7 +310,7 @@ class Season(Video):
def episodes(self, **kwargs):
""" Returs a list of Episode. """
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key, type=Episode.TYPE, **kwargs)
return self.fetchItems(key, **kwargs)
def episode(self, title=None, num=None):
""" Returns the episode with the given title or number.
@ -369,8 +363,9 @@ class Season(Video):
return downloaded
@utils.register_libtype
@utils.registerPlexObject
class Episode(Video, Playable):
TAG = 'Video'
TYPE = 'episode'
def _loadData(self, data):
@ -402,9 +397,9 @@ class Episode(Video, Playable):
self.rating = utils.cast(float, data.attrib.get('rating'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.year = utils.cast(int, data.attrib.get('year'))
self.directors = self._buildItems(data, media.Director, bytag=True)
self.media = self._buildItems(data, media.Media, bytag=True)
self.writers = self._buildItems(data, media.Writer, bytag=True)
self.directors = self.findItems(data, media.Director)
self.media = self.findItems(data, media.Media)
self.writers = self.findItems(data, media.Writer)
# data for active sessions and history
self.sessionKey = utils.cast(int, data.attrib.get('sessionKey'))
self.username = utils.findUsername(data)

View file

@ -279,11 +279,13 @@ def test_audio_Track_attrs(a_music_album):
def test_audio_Track_album(a_music_album):
assert a_music_album.tracks()[0].album() == a_music_album
tracks = a_music_album.tracks()
assert tracks[0].album() == a_music_album
def test_audio_Track_artist(a_music_album, a_artist):
assert a_music_album.tracks()[0].artist() == a_artist
tracks = a_music_album.tracks()
assert tracks[0].artist() == a_artist
def test_audio_Audio_section(a_artist, a_music_album, a_track):