diff --git a/lib/plexapi/__init__.py b/lib/plexapi/__init__.py new file mode 100644 index 00000000..48d28060 --- /dev/null +++ b/lib/plexapi/__init__.py @@ -0,0 +1,49 @@ +# -*- coding: utf-8 -*- +import logging +import os +from logging.handlers import RotatingFileHandler +from platform import uname +from plexapi.config import PlexConfig, reset_base_headers +from plexapi.utils import SecretsFilter +from uuid import getnode + +# Load User Defined Config +DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini') +CONFIG_PATH = os.environ.get('PLEXAPI_CONFIG_PATH', DEFAULT_CONFIG_PATH) +CONFIG = PlexConfig(CONFIG_PATH) + +# PlexAPI Settings +PROJECT = 'PlexAPI' +VERSION = '3.0.6' +TIMEOUT = CONFIG.get('plexapi.timeout', 30, int) +X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int) + +# Plex Header Configuation +X_PLEX_PROVIDES = CONFIG.get('header.provides', 'controller') +X_PLEX_PLATFORM = CONFIG.get('header.platorm', uname()[0]) +X_PLEX_PLATFORM_VERSION = CONFIG.get('header.platform_version', uname()[2]) +X_PLEX_PRODUCT = CONFIG.get('header.product', PROJECT) +X_PLEX_VERSION = CONFIG.get('header.version', VERSION) +X_PLEX_DEVICE = CONFIG.get('header.device', X_PLEX_PLATFORM) +X_PLEX_DEVICE_NAME = CONFIG.get('header.device_name', uname()[1]) +X_PLEX_IDENTIFIER = CONFIG.get('header.identifier', str(hex(getnode()))) +BASE_HEADERS = reset_base_headers() + +# Logging Configuration +log = logging.getLogger('plexapi') +logfile = CONFIG.get('log.path') +logformat = CONFIG.get('log.format', '%(asctime)s %(module)12s:%(lineno)-4s %(levelname)-9s %(message)s') +loglevel = CONFIG.get('log.level', 'INFO').upper() +loghandler = logging.NullHandler() + +if logfile: # pragma: no cover + logbackups = CONFIG.get('log.backup_count', 3, int) + logbytes = CONFIG.get('log.rotate_bytes', 512000, int) + loghandler = RotatingFileHandler(os.path.expanduser(logfile), 'a', logbytes, logbackups) + +loghandler.setFormatter(logging.Formatter(logformat)) +log.addHandler(loghandler) +log.setLevel(loglevel) +logfilter = SecretsFilter() +if CONFIG.get('log.show_secrets', '').lower() != 'true': + log.addFilter(logfilter) diff --git a/lib/plexapi/alert.py b/lib/plexapi/alert.py new file mode 100644 index 00000000..dc1c76e1 --- /dev/null +++ b/lib/plexapi/alert.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +import json +import threading +import websocket +from plexapi import log + + +class AlertListener(threading.Thread): + """ Creates a websocket connection to the PlexServer to optionally recieve alert notifications. + These often include messages from Plex about media scans as well as updates to currently running + Transcode Sessions. This class implements threading.Thread, therfore to start monitoring + alerts you must call .start() on the object once it's created. When calling + `PlexServer.startAlertListener()`, the thread will be started for you. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to. + callback (func): Callback function to call on recieved messages. The callback function + will be sent a single argument 'data' which will contain a dictionary of data + recieved from the server. :samp:`def my_callback(data): ...` + """ + key = '/:/websockets/notifications' + + def __init__(self, server, callback=None): + super(AlertListener, self).__init__() + self.daemon = True + self._server = server + self._callback = callback + self._ws = None + + def run(self): + # create the websocket connection + url = self._server.url(self.key, includeToken=True).replace('http', 'ws') + log.info('Starting AlertListener: %s', url) + self._ws = websocket.WebSocketApp(url, on_message=self._onMessage, + on_error=self._onError) + self._ws.run_forever() + + def stop(self): + """ Stop the AlertListener thread. Once the notifier is stopped, it cannot be diractly + started again. You must call :func:`plexapi.server.PlexServer.startAlertListener()` + from a PlexServer instance. + """ + log.info('Stopping AlertListener.') + self._ws.close() + + def _onMessage(self, ws, message): + """ Called when websocket message is recieved. """ + try: + data = json.loads(message)['NotificationContainer'] + log.debug('Alert: %s %s %s', *data) + if self._callback: + self._callback(data) + except Exception as err: # pragma: no cover + log.error('AlertListener Msg Error: %s', err) + + def _onError(self, ws, err): # pragma: no cover + """ Called when websocket error is recieved. """ + log.error('AlertListener Error: %s' % err) diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py new file mode 100644 index 00000000..6f6c0d98 --- /dev/null +++ b/lib/plexapi/audio.py @@ -0,0 +1,304 @@ +# -*- coding: utf-8 -*- +from plexapi import media, utils +from plexapi.base import Playable, PlexPartialObject + + +class Audio(PlexPartialObject): + """ Base class for audio :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album` + and :class:`~plexapi.audio.Track` objects. + + Attributes: + addedAt (datetime): Datetime this item was added to the library. + index (sting): Index Number (often the track number). + key (str): API URL (/library/metadata/). + lastViewedAt (datetime): Datetime item was last accessed. + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + listType (str): Hardcoded as 'audio' (useful for search filters). + ratingKey (int): Unique key identifying this item. + summary (str): Summary of the artist, track, or album. + thumb (str): URL to thumbnail image. + title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.) + titleSort (str): Title to use when sorting (defaults to title). + type (str): 'artist', 'album', or 'track'. + updatedAt (datatime): Datetime this item was updated. + viewCount (int): Count of times this item was accessed. + """ + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.listType = 'audio' + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.index = data.attrib.get('index') + self.key = data.attrib.get('key') + self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) + self.librarySectionID = data.attrib.get('librarySectionID') + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.summary = data.attrib.get('summary') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort', self.title) + self.type = data.attrib.get('type') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) + + @property + def thumbUrl(self): + """ Return url to for the thumbnail image. """ + key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') + return self._server.url(key, includeToken=True) if key else None + + @property + def artUrl(self): + """ Return the first art url starting on the most specific for that item.""" + art = self.firstAttr('art', 'grandparentArt') + return self._server.url(art, includeToken=True) if art else None + + def url(self, part): + """ Returns the full URL for this audio item. Typically used for getting a specific track. """ + return self._server.url(part, includeToken=True) if part else None + + +@utils.registerPlexObject +class Artist(Audio): + """ Represents a single audio artist. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'artist' + art (str): Artist artwork (/library/metadata//art/) + countries (list): List of :class:`~plexapi.media.Country` objects this artist respresents. + genres (list): List of :class:`~plexapi.media.Genre` objects this artist respresents. + guid (str): Unknown (unique ID; com.plexapp.agents.plexmusic://gracenote/artist/05517B8701668D28?lang=en) + key (str): API URL (/library/metadata/). + 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): + """ Load attribute values from Plex XML response. """ + Audio._loadData(self, data) + self.art = data.attrib.get('art') + self.guid = data.attrib.get('guid') + self.key = self.key.replace('/children', '') # FIX_BUG_50 + self.locations = self.listAttrs(data, 'path', etag='Location') + self.countries = self.findItems(data, media.Country) + self.genres = self.findItems(data, media.Genre) + self.similar = self.findItems(data, media.Similar) + self.collections = self.findItems(data, media.Collection) + + def __iter__(self): + for album in self.albums(): + yield album + + def album(self, title): + """ Returns the :class:`~plexapi.audio.Album` that matches the specified title. + + Parameters: + title (str): Title of the album to return. + """ + key = '%s/children' % self.key + return self.fetchItem(key, title__iexact=title) + + def albums(self, **kwargs): + """ Returns a list of :class:`~plexapi.audio.Album` objects by this artist. """ + key = '%s/children' % self.key + return self.fetchItems(key, **kwargs) + + def track(self, title): + """ Returns the :class:`~plexapi.audio.Track` that matches the specified title. + + Parameters: + title (str): Title of the track to return. + """ + key = '%s/allLeaves' % self.key + return self.fetchItem(key, title__iexact=title) + + def tracks(self, **kwargs): + """ Returns a list of :class:`~plexapi.audio.Track` objects by this artist. """ + key = '%s/allLeaves' % self.key + return self.fetchItems(key, **kwargs) + + def get(self, title): + """ Alias of :func:`~plexapi.audio.Artist.track`. """ + return self.track(title) + + def download(self, savepath=None, keep_orginal_name=False, **kwargs): + """ Downloads all tracks for this artist to the specified location. + + Parameters: + savepath (str): Title of the track to return. + keep_orginal_name (bool): Set True to keep the original filename as stored in + the Plex server. False will create a new filename with the format + " - ". + kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will + be returned and the additional arguments passed in will be sent to that + function. If kwargs is not specified, the media items will be downloaded + and saved to disk. + """ + filepaths = [] + for album in self.albums(): + for track in album.tracks(): + filepaths += track.download(savepath, keep_orginal_name, **kwargs) + return filepaths + + +@utils.registerPlexObject +class Album(Audio): + """ Represents a single audio album. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'album' + art (str): Album artwork (/library/metadata//art/) + genres (list): List of :class:`~plexapi.media.Genre` objects this album respresents. + key (str): API URL (/library/metadata/). + originallyAvailableAt (datetime): Datetime this album was released. + parentKey (str): API URL of this artist. + parentRatingKey (int): Unique key identifying artist. + parentThumb (str): URL to artist thumbnail image. + parentTitle (str): Name of the artist for this album. + studio (str): Studio that released this album. + year (int): Year this album was released. + """ + TAG = 'Directory' + TYPE = 'album' + + def __iter__(self): + for track in self.tracks: + yield track + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Audio._loadData(self, data) + self.art = data.attrib.get('art') + self.key = self.key.replace('/children', '') # fixes bug #50 + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.parentKey = data.attrib.get('parentKey') + self.parentRatingKey = data.attrib.get('parentRatingKey') + self.parentThumb = data.attrib.get('parentThumb') + self.parentTitle = data.attrib.get('parentTitle') + self.studio = data.attrib.get('studio') + self.year = utils.cast(int, data.attrib.get('year')) + self.genres = self.findItems(data, media.Genre) + self.collections = self.findItems(data, media.Collection) + self.labels = self.findItems(data, media.Label) + + def track(self, title): + """ Returns the :class:`~plexapi.audio.Track` that matches the specified title. + + Parameters: + title (str): Title of the track to return. + """ + key = '%s/children' % self.key + return self.fetchItem(key, title__iexact=title) + + def tracks(self, **kwargs): + """ Returns a list of :class:`~plexapi.audio.Track` objects in this album. """ + key = '%s/children' % self.key + return self.fetchItems(key, **kwargs) + + def get(self, title): + """ Alias of :func:`~plexapi.audio.Album.track`. """ + return self.track(title) + + def artist(self): + """ Return :func:`~plexapi.audio.Artist` of this album. """ + return self.fetchItem(self.parentKey) + + def download(self, savepath=None, keep_orginal_name=False, **kwargs): + """ Downloads all tracks for this artist to the specified location. + + Parameters: + savepath (str): Title of the track to return. + keep_orginal_name (bool): Set True to keep the original filename as stored in + the Plex server. False will create a new filename with the format + " - ". + kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will + be returned and the additional arguments passed in will be sent to that + function. If kwargs is not specified, the media items will be downloaded + and saved to disk. + """ + filepaths = [] + for track in self.tracks(): + filepaths += track.download(savepath, keep_orginal_name, **kwargs) + return filepaths + + +@utils.registerPlexObject +class Track(Audio, Playable): + """ Represents a single audio track. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'track' + art (str): Track artwork (/library/metadata//art/) + chapterSource (TYPE): Unknown + duration (int): Length of this album in seconds. + grandparentArt (str): Artist artowrk. + grandparentKey (str): Artist API URL. + grandparentRatingKey (str): Unique key identifying artist. + grandparentThumb (str): URL to artist thumbnail image. + grandparentTitle (str): Name of the artist for this track. + guid (str): Unknown (unique ID). + media (list): List of :class:`~plexapi.media.Media` objects for this track. + moods (list): List of :class:`~plexapi.media.Mood` objects for this track. + originalTitle (str): Original track title (if translated). + parentIndex (int): Album index. + parentKey (str): Album API URL. + parentRatingKey (int): Unique key identifying album. + parentThumb (str): URL to album thumbnail image. + parentTitle (str): Name of the album for this track. + primaryExtraKey (str): Unknown + ratingCount (int): Unknown + userRating (float): Rating of this track (0.0 - 10.0) equaling (0 stars - 5 stars) + viewOffset (int): Unknown + year (int): Year this track was released. + sessionKey (int): Session Key (active sessions only). + usernames (str): Username of person playing this track (active sessions only). + player (str): :class:`~plexapi.client.PlexClient` for playing track (active sessions only). + transcodeSessions (None): :class:`~plexapi.media.TranscodeSession` for playing + track (active sessions only). + """ + TAG = 'Track' + TYPE = 'track' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Audio._loadData(self, data) + Playable._loadData(self, data) + self.art = data.attrib.get('art') + self.chapterSource = data.attrib.get('chapterSource') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.grandparentArt = data.attrib.get('grandparentArt') + self.grandparentKey = data.attrib.get('grandparentKey') + self.grandparentRatingKey = data.attrib.get('grandparentRatingKey') + self.grandparentThumb = data.attrib.get('grandparentThumb') + self.grandparentTitle = data.attrib.get('grandparentTitle') + self.guid = data.attrib.get('guid') + self.originalTitle = data.attrib.get('originalTitle') + self.parentIndex = data.attrib.get('parentIndex') + self.parentKey = data.attrib.get('parentKey') + self.parentRatingKey = data.attrib.get('parentRatingKey') + self.parentThumb = data.attrib.get('parentThumb') + self.parentTitle = data.attrib.get('parentTitle') + self.primaryExtraKey = data.attrib.get('primaryExtraKey') + self.ratingCount = utils.cast(int, data.attrib.get('ratingCount')) + self.userRating = utils.cast(float, data.attrib.get('userRating', 0)) + self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) + self.year = utils.cast(int, data.attrib.get('year')) + self.media = self.findItems(data, media.Media) + self.moods = self.findItems(data, media.Mood) + + def _prettyfilename(self): + """ Returns a filename for use in download. """ + return '%s - %s %s' % (self.grandparentTitle, self.parentTitle, self.title) + + def album(self): + """ Return this track's :class:`~plexapi.audio.Album`. """ + return self.fetchItem(self.parentKey) + + def artist(self): + """ Return this track's :class:`~plexapi.audio.Artist`. """ + return self.fetchItem(self.grandparentKey) diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py new file mode 100644 index 00000000..f452e3e1 --- /dev/null +++ b/lib/plexapi/base.py @@ -0,0 +1,581 @@ +# -*- coding: utf-8 -*- +import re + +from plexapi import log, utils +from plexapi.compat import quote_plus, urlencode +from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported +from plexapi.utils import tag_helper + +OPERATORS = { + 'exact': lambda v, q: v == q, + 'iexact': lambda v, q: v.lower() == q.lower(), + 'contains': lambda v, q: q in v, + 'icontains': lambda v, q: q.lower() in v.lower(), + 'ne': lambda v, q: v != q, + 'in': lambda v, q: v in q, + 'gt': lambda v, q: v > q, + 'gte': lambda v, q: v >= q, + 'lt': lambda v, q: v < q, + 'lte': lambda v, q: v <= q, + 'startswith': lambda v, q: v.startswith(q), + 'istartswith': lambda v, q: v.lower().startswith(q), + 'endswith': lambda v, q: v.endswith(q), + 'iendswith': lambda v, q: v.lower().endswith(q), + '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), +} + + +class PlexObject(object): + """ Base class for all Plex objects. + + 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._details_key = '' + if data is not None: + self._loadData(data) + + def __repr__(self): + uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri')) + name = self._clean(self.firstAttr('title', 'name', 'username', 'product', 'tag', 'value')) + return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid, name] 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 _clean(self, value): + """ Clean attr value for display in __repr__. """ + if value: + value = str(value).replace('/library/metadata/', '') + value = value.replace('/children', '') + return value.replace(' ', '-')[:20] + + 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 + if cls is not None: + return cls(self._server, elem, initpath) + # 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)) + # log.debug('Building %s as %s', elem.tag, ecls.__name__) + 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): + """ Calls :func:`~plexapi.base.PlexObject._buildItem()` but returns + None if elem is an unknown type. + """ + try: + return self._buildItem(elem, cls, initpath) + except UnknownType: + return None + + 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. + + Parameters: + ekey (str or int): Path in Plex to fetch items from. If an int is passed + in, the key will be translated to /library/metadata/. This allows + fetching an item only knowing its key-id. + 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 + 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. + Note: Because some attribute names are already used as arguments to this + function, such as 'tag', you may still reference the attr tag byappending + an underscore. For example, passing in _tag='foobar' will return all items + where tag='foobar'. Also Note: Case very much matters when specifying kwargs + -- Optionally, operators can be specified by append it + to the end of the attribute name for more complex lookups. For example, + passing in viewCount__gte=0 will return all items where viewCount >= 0. + Available operations include: + + * __contains: Value contains specified arg. + * __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. + * __regex: Value matches the specified regular expression. + * __startswith: Value starts with specified arg. + """ + 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, 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 data: + if self._checkAttrs(elem, **kwargs): + item = self._buildItemOrNone(elem, cls, initpath) + if item is not None: + items.append(item) + return items + + def firstAttr(self, *attrs): + """ Return the first attribute in attrs that is not None. """ + for attr in attrs: + value = self.__dict__.get(attr) + if value is not None: + return value + + def listAttrs(self, data, attr, **kwargs): + results = [] + for elem in data: + kwargs['%s__exists' % attr] = True + if self._checkAttrs(elem, **kwargs): + results.append(elem.attrib.get(attr)) + return results + + def reload(self, key=None): + """ Reload the data for this object from self.key. """ + key = key or self._details_key or self.key + if not key: + raise Unsupported('Cannot reload an object not built from a URL.') + self._initpath = key + data = self._server.query(key) + self._loadData(data[0]) + return self + + def _checkAttrs(self, elem, **kwargs): + attrsFound = {} + for attr, query in kwargs.items(): + attr, op, operator = self._getAttrOperator(attr) + values = self._getAttrValue(elem, attr) + # 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: + 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): + for op, operator in OPERATORS.items(): + if attr.endswith('__%s' % op): + attr = attr.rsplit('__', 1)[0] + return attr, op, operator + # default to exact match + return attr, 'exact', OPERATORS['exact'] + + def _getAttrValue(self, elem, attrstr, results=None): + # log.debug('Fetching %s in %s', attrstr, elem.tag) + parts = attrstr.split('__', 1) + attr = parts[0] + attrstr = parts[1] if len(parts) == 2 else None + if attrstr: + results = [] if results is None else results + 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.') + + +class PlexPartialObject(PlexObject): + """ Not all objects in the Plex listings return the complete list of elements + for the object. This object will allow you to assume each object is complete, + and if the specified value you request is None it will fetch the full object + automatically and update itself. + """ + + def __eq__(self, other): + return other is not None and self.key == other.key + + def __hash__(self): + return hash(repr(self)) + + def __iter__(self): + yield self + + def __getattribute__(self, attr): + # Dragons inside.. :-/ + value = super(PlexPartialObject, self).__getattribute__(attr) + # Check a few cases where we dont want to reload + if attr == 'key' or attr.startswith('_'): return value + if value not in (None, []): return value + if self.isFullObject(): return value + # Log the reload. + clsname = self.__class__.__name__ + title = self.__dict__.get('title', self.__dict__.get('name')) + objname = "%s '%s'" % (clsname, title) if title else clsname + log.debug("Reloading %s for attr '%s'" % (objname, attr)) + # Reload and return the value + self.reload() + return super(PlexPartialObject, self).__getattribute__(attr) + + def analyze(self): + """ Tell Plex Media Server to performs analysis on it this item to gather + information. Analysis includes: + + * Gather Media Properties: All of the media you add to a Library has + properties that are useful to know–whether it's a video file, a + music track, or one of your photos (container, codec, resolution, etc). + * Generate Default Artwork: Artwork will automatically be grabbed from a + video file. A background image will be pulled out as well as a + smaller image to be used for poster/thumbnail type purposes. + * Generate Video Preview Thumbnails: Video preview thumbnails are created, + if you have that feature enabled. Video preview thumbnails allow + graphical seeking in some Apps. It's also used in the Plex Web App Now + Playing screen to show a graphical representation of where playback + is. Video preview thumbnails creation is a CPU-intensive process akin + to transcoding the file. + """ + key = '/%s/analyze' % self.key.lstrip('/') + self._server.query(key, method=self._server._session.put) + + def isFullObject(self): + """ Retruns True if this is already a full object. A full object means all attributes + were populated from the api path representing only this item. For example, the + search result for a movie often only contain a portion of the attributes a full + object (main url) for that movie contain. + """ + return not self.key or self.key == self._initpath + + def isPartialObject(self): + """ Returns True if this is not a full object. """ + return not self.isFullObject() + + def edit(self, **kwargs): + """ Edit an object. + + Parameters: + kwargs (dict): Dict of settings to edit. + + Example: + {'type': 1, + 'id': movie.ratingKey, + 'collection[0].tag.tag': 'Super', + 'collection.locked': 0} + """ + if 'id' not in kwargs: + kwargs['id'] = self.ratingKey + if 'type' not in kwargs: + kwargs['type'] = utils.searchType(self.type) + + part = '/library/sections/%s/all?%s' % (self.librarySectionID, + urlencode(kwargs)) + self._server.query(part, method=self._server._session.put) + + def _edit_tags(self, tag, items, locked=True, remove=False): + """ Helper to edit and refresh a tags. + + Parameters: + tag (str): tag name + items (list): list of tags to add + locked (bool): lock this field. + remove (bool): If this is active remove the tags in items. + """ + if not isinstance(items, list): + items = [items] + value = getattr(self, tag + 's') + existing_cols = [t.tag for t in value if t and remove is False] + d = tag_helper(tag, existing_cols + items, locked, remove) + self.edit(**d) + self.refresh() + + def addCollection(self, collections): + """ Add a collection(s). + + Parameters: + collections (list): list of strings + """ + self._edit_tags('collection', collections) + + def removeCollection(self, collections): + """ Remove a collection(s). """ + self._edit_tags('collection', collections, remove=True) + + def addLabel(self, labels): + """ Add a label(s). """ + self._edit_tags('label', labels) + + def removeLabel(self, labels): + """ Remove a label(s). """ + self._edit_tags('label', labels, remove=True) + + def addGenre(self, genres): + """ Add a genre(s). """ + self._edit_tags('genre', genres) + + def removeGenre(self, genres): + """ Remove a genre(s). """ + self._edit_tags('genre', genres, remove=True) + + def refresh(self): + """ Refreshing a Library or individual item causes the metadata for the item to be + refreshed, even if it already has metadata. You can think of refreshing as + "update metadata for the requested item even if it already has some". You should + refresh a Library or individual item if: + + * You've changed the Library Metadata Agent. + * You've added "Local Media Assets" (such as artwork, theme music, external + subtitle files, etc.) + * You want to freshen the item posters, summary, etc. + * There's a problem with the poster image that's been downloaded. + * Items are missing posters or other downloaded information. This is possible if + the refresh process is interrupted (the Server is turned off, internet + connection dies, etc). + """ + key = '%s/refresh' % self.key + self._server.query(key, method=self._server._session.put) + + def section(self): + """ Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """ + return self._server.library.sectionByID(self.librarySectionID) + + def delete(self): + """ Delete a media element. This has to be enabled under settings > server > library in plex webui. """ + try: + return self._server.query(self.key, method=self._server._session.delete) + except BadRequest: # pragma: no cover + log.error('Failed to delete %s. This could be because you ' + 'havnt allowed items to be deleted' % self.key) + raise + + # The photo tag cant be built atm. TODO + # def arts(self): + # part = '%s/arts' % self.key + # return self.fetchItem(part) + + # def poster(self): + # part = '%s/posters' % self.key + # return self.fetchItem(part, etag='Photo') + + +class Playable(object): + """ This is a general place to store functions specific to media that is Playable. + Things were getting mixed up a bit when dealing with Shows, Season, Artists, + Albums which are all not playable. + + Attributes: + sessionKey (int): Active session key. + usernames (str): Username of the person playing this item (for active sessions). + players (:class:`~plexapi.client.PlexClient`): Client objects playing this item (for active sessions). + session (:class:`~plexapi.media.Session`): Session object, for a playing media file. + transcodeSessions (:class:`~plexapi.media.TranscodeSession`): Transcode Session object + if item is being transcoded (None otherwise). + viewedAt (datetime): Datetime item was last viewed (history). + playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items). + """ + + def _loadData(self, data): + self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) # session + self.usernames = self.listAttrs(data, 'title', etag='User') # session + self.players = self.findItems(data, etag='Player') # session + self.transcodeSessions = self.findItems(data, etag='TranscodeSession') # session + self.session = self.findItems(data, etag='Session') # session + self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history + self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist + + def isFullObject(self): + """ Retruns True if this is already a full object. A full object means all attributes + were populated from the api path representing only this item. For example, the + search result for a movie often only contain a portion of the attributes a full + object (main url) for that movie contain. + """ + return self._details_key == self._initpath or not self.key + + def getStreamURL(self, **params): + """ Returns a stream url that may be used by external applications such as VLC. + + Parameters: + **params (dict): optional parameters to manipulate the playback when accessing + the stream. A few known parameters include: maxVideoBitrate, videoResolution + offset, copyts, protocol, mediaIndex, platform. + + Raises: + Unsupported: When the item doesn't support fetching a stream URL. + """ + if self.TYPE not in ('movie', 'episode', 'track'): + raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE) + mvb = params.get('maxVideoBitrate') + vr = params.get('videoResolution', '') + params = { + 'path': self.key, + 'offset': params.get('offset', 0), + 'copyts': params.get('copyts', 1), + 'protocol': params.get('protocol'), + 'mediaIndex': params.get('mediaIndex', 0), + 'X-Plex-Platform': params.get('platform', 'Chrome'), + 'maxVideoBitrate': max(mvb, 64) if mvb else None, + 'videoResolution': vr if re.match('^\d+x\d+$', vr) else None + } + # remove None values + params = {k: v for k, v in params.items() if v is not None} + streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video' + # sort the keys since the randomness fucks with my tests.. + sorted_params = sorted(params.items(), key=lambda val: val[0]) + return self._server.url('/%s/:/transcode/universal/start.m3u8?%s' % + (streamtype, urlencode(sorted_params)), includeToken=True) + + def iterParts(self): + """ Iterates over the parts of this media item. """ + for item in self.media: + for part in item.parts: + yield part + + def split(self): + """Split a duplicate.""" + key = '%s/split' % self.key + return self._server.query(key, method=self._server._session.put) + + def unmatch(self): + """Unmatch a media file.""" + key = '%s/unmatch' % self.key + return self._server.query(key, method=self._server._session.put) + + def play(self, client): + """ Start playback on the specified client. + + Parameters: + client (:class:`~plexapi.client.PlexClient`): Client to start playing on. + """ + client.playMedia(self) + + def download(self, savepath=None, keep_orginal_name=False, **kwargs): + """ Downloads this items media to the specified location. Returns a list of + filepaths that have been saved to disk. + + Parameters: + savepath (str): Title of the track to return. + keep_orginal_name (bool): Set True to keep the original filename as stored in + the Plex server. False will create a new filename with the format + " - ". + kwargs (dict): If specified, a :func:`~plexapi.audio.Track.getStreamURL()` will + be returned and the additional arguments passed in will be sent to that + function. If kwargs is not specified, the media items will be downloaded + and saved to disk. + """ + filepaths = [] + locations = [i for i in self.iterParts() if i] + for location in locations: + filename = location.file + if keep_orginal_name is False: + filename = '%s.%s' % (self._prettyfilename(), location.container) + # So this seems to be a alot slower but allows transcode. + if kwargs: + download_url = self.getStreamURL(**kwargs) + else: + download_url = self._server.url('%s?download=1' % location.key) + filepath = utils.download(download_url, self._server._token, filename=filename, + savepath=savepath, session=self._server._session) + if filepath: + filepaths.append(filepath) + return filepaths + + def stop(self, reason=''): + """ Stop playback for a media item. """ + key = '/status/sessions/terminate?sessionId=%s&reason=%s' % (self.session[0].id, quote_plus(reason)) + return self._server.query(key) + + def updateProgress(self, time, state='stopped'): + """ Set the watched progress for this video. + + Note that setting the time to 0 will not work. + Use `markWatched` or `markUnwatched` to achieve + that goal. + + Parameters: + time (int): milliseconds watched + state (string): state of the video, default 'stopped' + """ + key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey, + time, state) + self._server.query(key) + self.reload() + + +@utils.registerPlexObject +class Release(PlexObject): + TAG = 'Release' + key = '/updater/status' + + def _loadData(self, data): + self.download_key = data.attrib.get('key') + self.version = data.attrib.get('version') + self.added = data.attrib.get('added') + self.fixed = data.attrib.get('fixed') + self.downloadURL = data.attrib.get('downloadURL') + self.state = data.attrib.get('state') diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py new file mode 100644 index 00000000..77185e30 --- /dev/null +++ b/lib/plexapi/client.py @@ -0,0 +1,527 @@ +# -*- coding: utf-8 -*- + +import requests + +from requests.status_codes import _codes as codes +from plexapi import BASE_HEADERS, CONFIG, TIMEOUT +from plexapi import log, logfilter, utils +from plexapi.base import PlexObject +from plexapi.compat import ElementTree +from plexapi.exceptions import BadRequest, Unsupported +from plexapi.playqueue import PlayQueue + + +DEFAULT_MTYPE = 'video' + + +@utils.registerPlexObject +class PlexClient(PlexObject): + """ Main class for interacting with a Plex client. This class can connect + directly to the client and control it or proxy commands through your + Plex Server. To better understand the Plex client API's read this page: + https://github.com/plexinc/plex-media-player/wiki/Remote-control-API + + 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): Path used to generate data. + baseurl (str): HTTP URL to connect dirrectly to this client. + token (str): X-Plex-Token used for authenication (optional). + session (:class:`~requests.Session`): requests.Session object if you want more control (optional). + timeout (int): timeout in seconds on initial connect to client (default config.TIMEOUT). + + Attributes: + TAG (str): 'Player' + key (str): '/resources' + device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc). + deviceClass (str): Device class (pc, phone, etc). + machineIdentifier (str): Unique ID for this device. + model (str): Unknown + platform (str): Unknown + platformVersion (str): Description + product (str): Client Product (Plex for iOS, etc). + protocol (str): Always seems ot be 'plex'. + protocolCapabilities (list): List of client capabilities (navigation, playback, + timeline, mirror, playqueues). + protocolVersion (str): Protocol version (1, future proofing?) + server (:class:`~plexapi.server.PlexServer`): Server this client is connected to. + session (:class:`~requests.Session`): Session object used for connection. + state (str): Unknown + title (str): Name of this client (Johns iPhone, etc). + token (str): X-Plex-Token used for authenication + vendor (str): Unknown + version (str): Device version (4.6.1, etc). + _baseurl (str): HTTP address of the client. + _token (str): Token used to access this client. + _session (obj): Requests session object used to access this client. + _proxyThroughServer (bool): Set to True after calling + :func:`~plexapi.client.PlexClient.proxyThroughServer()` (default False). + """ + TAG = 'Player' + key = '/resources' + + def __init__(self, server=None, data=None, initpath=None, baseurl=None, + token=None, connect=True, session=None, timeout=None): + super(PlexClient, self).__init__(server, data, initpath) + self._baseurl = baseurl.strip('/') if baseurl else None + self._token = logfilter.add_secret(token) + self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' + server_session = server._session if server else None + self._session = session or server_session or requests.Session() + self._proxyThroughServer = False + self._commandId = 0 + if not any([data, initpath, baseurl, token]): + self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433') + self._token = logfilter.add_secret(CONFIG.get('auth.client_token')) + if connect and self._baseurl: + self.connect(timeout=timeout) + + def _nextCommandId(self): + self._commandId += 1 + return self._commandId + + def connect(self, timeout=None): + """ Alias of reload as any subsequent requests to this client will be + made directly to the device even if the object attributes were initially + populated from a PlexServer. + """ + if not self.key: + raise Unsupported('Cannot reload an object not built from a URL.') + self._initpath = self.key + data = self.query(self.key, timeout=timeout) + self._loadData(data[0]) + return self + + def reload(self): + """ Alias to self.connect(). """ + return self.connect() + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.deviceClass = data.attrib.get('deviceClass') + self.machineIdentifier = data.attrib.get('machineIdentifier') + self.product = data.attrib.get('product') + self.protocol = data.attrib.get('protocol') + self.protocolCapabilities = data.attrib.get('protocolCapabilities', '').split(',') + self.protocolVersion = data.attrib.get('protocolVersion') + self.platform = data.attrib.get('platform') + self.platformVersion = data.attrib.get('platformVersion') + self.title = data.attrib.get('title') or data.attrib.get('name') + # Active session details + # Since protocolCapabilities is missing from /sessions we cant really control this player without + # creating a client manually. + # Add this in next breaking release. + # if self._initpath == 'status/sessions': + self.device = data.attrib.get('device') # session + self.model = data.attrib.get('model') # session + self.state = data.attrib.get('state') # session + self.vendor = data.attrib.get('vendor') # session + self.version = data.attrib.get('version') # session + self.local = utils.cast(bool, data.attrib.get('local', 0)) + self.address = data.attrib.get('address') # session + self.remotePublicAddress = data.attrib.get('remotePublicAddress') + self.userID = data.attrib.get('userID') + + def _headers(self, **kwargs): + """ Returns a dict of all default headers for Client requests. """ + headers = BASE_HEADERS + if self._token: + headers['X-Plex-Token'] = self._token + headers.update(kwargs) + return headers + + def proxyThroughServer(self, value=True, server=None): + """ Tells this PlexClient instance to proxy all future commands through the PlexServer. + Useful if you do not wish to connect directly to the Client device itself. + + Parameters: + value (bool): Enable or disable proxying (optional, default True). + + Raises: + :class:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server. + """ + if server: + self._server = server + if value is True and not self._server: + raise Unsupported('Cannot use client proxy with unknown server.') + self._proxyThroughServer = value + + def query(self, path, method=None, headers=None, timeout=None, **kwargs): + """ Main method used to handle HTTPS requests to the Plex client. This method helps + by encoding the response to utf-8 and parsing the returned XML into and + ElementTree object. Returns None if no data exists in the response. + """ + url = self.url(path) + method = method or self._session.get + timeout = timeout or TIMEOUT + log.debug('%s %s', method.__name__.upper(), url) + headers = self._headers(**headers or {}) + response = method(url, headers=headers, timeout=timeout, **kwargs) + if response.status_code not in (200, 201): + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) + raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)) + data = response.text.encode('utf8') + return ElementTree.fromstring(data) if data.strip() else None + + def sendCommand(self, command, proxy=None, **params): + """ Convenience wrapper around :func:`~plexapi.client.PlexClient.query()` to more easily + send simple commands to the client. Returns an ElementTree object containing + the response. + + Parameters: + command (str): Command to be sent in for format '/'. + proxy (bool): Set True to proxy this command through the PlexServer. + **params (dict): Additional GET parameters to include with the command. + + Raises: + :class:`~plexapi.exceptions.Unsupported`: When we detect the client + doesn't support this capability. + """ + command = command.strip('/') + controller = command.split('/')[0] + if controller not in self.protocolCapabilities: + log.debug('Client %s doesnt support %s controller.' + 'What your trying might not work' % (self.title, controller)) + + params['commandID'] = self._nextCommandId() + key = '/player/%s%s' % (command, utils.joinArgs(params)) + headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier} + proxy = self._proxyThroughServer if proxy is None else proxy + if proxy: + return self._server.query(key, headers=headers) + return self.query(key, headers=headers) + + def url(self, key, includeToken=False): + """ Build a URL string with proper token argument. Token will be appended to the URL + if either includeToken is True or CONFIG.log.show_secrets is 'true'. + """ + if not self._baseurl: + raise BadRequest('PlexClient object missing baseurl.') + if self._token and (includeToken or self._showSecrets): + delim = '&' if '?' in key else '?' + return '%s%s%sX-Plex-Token=%s' % (self._baseurl, key, delim, self._token) + return '%s%s' % (self._baseurl, key) + + # --------------------- + # Navigation Commands + # These commands navigate around the user-interface. + def contextMenu(self): + """ Open the context menu on the client. """ + self.sendCommand('navigation/contextMenu') + + def goBack(self): + """ Navigate back one position. """ + self.sendCommand('navigation/back') + + def goToHome(self): + """ Go directly to the home screen. """ + self.sendCommand('navigation/home') + + def goToMusic(self): + """ Go directly to the playing music panel. """ + self.sendCommand('navigation/music') + + def moveDown(self): + """ Move selection down a position. """ + self.sendCommand('navigation/moveDown') + + def moveLeft(self): + """ Move selection left a position. """ + self.sendCommand('navigation/moveLeft') + + def moveRight(self): + """ Move selection right a position. """ + self.sendCommand('navigation/moveRight') + + def moveUp(self): + """ Move selection up a position. """ + self.sendCommand('navigation/moveUp') + + def nextLetter(self): + """ Jump to next letter in the alphabet. """ + self.sendCommand('navigation/nextLetter') + + def pageDown(self): + """ Move selection down a full page. """ + self.sendCommand('navigation/pageDown') + + def pageUp(self): + """ Move selection up a full page. """ + self.sendCommand('navigation/pageUp') + + def previousLetter(self): + """ Jump to previous letter in the alphabet. """ + self.sendCommand('navigation/previousLetter') + + def select(self): + """ Select element at the current position. """ + self.sendCommand('navigation/select') + + def toggleOSD(self): + """ Toggle the on screen display during playback. """ + self.sendCommand('navigation/toggleOSD') + + def goToMedia(self, media, **params): + """ Navigate directly to the specified media page. + + Parameters: + media (:class:`~plexapi.media.Media`): Media object to navigate to. + **params (dict): Additional GET parameters to include with the command. + + Raises: + :class:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object. + """ + if not self._server: + raise Unsupported('A server must be specified before using this command.') + server_url = media._server._baseurl.split(':') + self.sendCommand('mirror/details', **dict({ + 'machineIdentifier': self._server.machineIdentifier, + 'address': server_url[1].strip('/'), + 'port': server_url[-1], + 'key': media.key, + }, **params)) + + # ------------------- + # Playback Commands + # Most of the playback commands take a mandatory mtype {'music','photo','video'} argument, + # to specify which media type to apply the command to, (except for playMedia). This + # is in case there are multiple things happening (e.g. music in the background, photo + # slideshow in the foreground). + def pause(self, mtype=DEFAULT_MTYPE): + """ Pause the currently playing media type. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/pause', type=mtype) + + def play(self, mtype=DEFAULT_MTYPE): + """ Start playback for the specified media type. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/play', type=mtype) + + def refreshPlayQueue(self, playQueueID, mtype=DEFAULT_MTYPE): + """ Refresh the specified Playqueue. + + Parameters: + playQueueID (str): Playqueue ID. + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand( + 'playback/refreshPlayQueue', playQueueID=playQueueID, type=mtype) + + def seekTo(self, offset, mtype=DEFAULT_MTYPE): + """ Seek to the specified offset (ms) during playback. + + Parameters: + offset (int): Position to seek to (milliseconds). + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/seekTo', offset=offset, type=mtype) + + def skipNext(self, mtype=DEFAULT_MTYPE): + """ Skip to the next playback item. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/skipNext', type=mtype) + + def skipPrevious(self, mtype=DEFAULT_MTYPE): + """ Skip to previous playback item. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/skipPrevious', type=mtype) + + def skipTo(self, key, mtype=DEFAULT_MTYPE): + """ Skip to the playback item with the specified key. + + Parameters: + key (str): Key of the media item to skip to. + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/skipTo', key=key, type=mtype) + + def stepBack(self, mtype=DEFAULT_MTYPE): + """ Step backward a chunk of time in the current playback item. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/stepBack', type=mtype) + + def stepForward(self, mtype=DEFAULT_MTYPE): + """ Step forward a chunk of time in the current playback item. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/stepForward', type=mtype) + + def stop(self, mtype=DEFAULT_MTYPE): + """ Stop the currently playing item. + + Parameters: + mtype (str): Media type to take action against (music, photo, video). + """ + self.sendCommand('playback/stop', type=mtype) + + def setRepeat(self, repeat, mtype=DEFAULT_MTYPE): + """ Enable repeat for the specified playback items. + + Parameters: + repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall). + mtype (str): Media type to take action against (music, photo, video). + """ + self.setParameters(repeat=repeat, mtype=mtype) + + def setShuffle(self, shuffle, mtype=DEFAULT_MTYPE): + """ Enable shuffle for the specified playback items. + + Parameters: + shuffle (int): Shuffle mode (0=off, 1=on) + mtype (str): Media type to take action against (music, photo, video). + """ + self.setParameters(shuffle=shuffle, mtype=mtype) + + def setVolume(self, volume, mtype=DEFAULT_MTYPE): + """ Enable volume for the current playback item. + + Parameters: + volume (int): Volume level (0-100). + mtype (str): Media type to take action against (music, photo, video). + """ + self.setParameters(volume=volume, mtype=mtype) + + def setAudioStream(self, audioStreamID, mtype=DEFAULT_MTYPE): + """ Select the audio stream for the current playback item (only video). + + Parameters: + audioStreamID (str): ID of the audio stream from the media object. + mtype (str): Media type to take action against (music, photo, video). + """ + self.setStreams(audioStreamID=audioStreamID, mtype=mtype) + + def setSubtitleStream(self, subtitleStreamID, mtype=DEFAULT_MTYPE): + """ Select the subtitle stream for the current playback item (only video). + + Parameters: + subtitleStreamID (str): ID of the subtitle stream from the media object. + mtype (str): Media type to take action against (music, photo, video). + """ + self.setStreams(subtitleStreamID=subtitleStreamID, mtype=mtype) + + def setVideoStream(self, videoStreamID, mtype=DEFAULT_MTYPE): + """ Select the video stream for the current playback item (only video). + + Parameters: + videoStreamID (str): ID of the video stream from the media object. + mtype (str): Media type to take action against (music, photo, video). + """ + self.setStreams(videoStreamID=videoStreamID, mtype=mtype) + + def playMedia(self, media, offset=0, **params): + """ Start playback of the specified media item. See also: + + Parameters: + media (:class:`~plexapi.media.Media`): Media item to be played back + (movie, music, photo, playlist, playqueue). + offset (int): Number of milliseconds at which to start playing with zero + representing the beginning (default 0). + **params (dict): Optional additional parameters to include in the playback request. See + also: https://github.com/plexinc/plex-media-player/wiki/Remote-control-API#modified-commands + + Raises: + :class:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object. + """ + if not self._server: + raise Unsupported('A server must be specified before using this command.') + server_url = media._server._baseurl.split(':') + + if self.product != 'OpenPHT': + try: + self.sendCommand('timeline/subscribe', port=server_url[1].strip('/'), protocol='http') + except: # noqa: E722 + # some clients dont need or like this and raises http 400. + # We want to include the exception in the log, + # but it might still work so we swallow it. + log.exception('%s failed to subscribe ' % self.title) + + playqueue = media if isinstance(media, PlayQueue) else self._server.createPlayQueue(media) + self.sendCommand('playback/playMedia', **dict({ + 'machineIdentifier': self._server.machineIdentifier, + 'address': server_url[1].strip('/'), + 'port': server_url[-1], + 'offset': offset, + 'key': media.key, + 'token': media._server._token, + 'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID, + }, **params)) + + def setParameters(self, volume=None, shuffle=None, repeat=None, mtype=DEFAULT_MTYPE): + """ Set multiple playback parameters at once. + + Parameters: + volume (int): Volume level (0-100; optional). + shuffle (int): Shuffle mode (0=off, 1=on; optional). + repeat (int): Repeat mode (0=off, 1=repeatone, 2=repeatall; optional). + mtype (str): Media type to take action against (optional music, photo, video). + """ + params = {} + if repeat is not None: + params['repeat'] = repeat + if shuffle is not None: + params['shuffle'] = shuffle + if volume is not None: + params['volume'] = volume + if mtype is not None: + params['type'] = mtype + self.sendCommand('playback/setParameters', **params) + + def setStreams(self, audioStreamID=None, subtitleStreamID=None, videoStreamID=None, mtype=DEFAULT_MTYPE): + """ Select multiple playback streams at once. + + Parameters: + audioStreamID (str): ID of the audio stream from the media object. + subtitleStreamID (str): ID of the subtitle stream from the media object. + videoStreamID (str): ID of the video stream from the media object. + mtype (str): Media type to take action against (optional music, photo, video). + """ + params = {} + if audioStreamID is not None: + params['audioStreamID'] = audioStreamID + if subtitleStreamID is not None: + params['subtitleStreamID'] = subtitleStreamID + if videoStreamID is not None: + params['videoStreamID'] = videoStreamID + if mtype is not None: + params['type'] = mtype + self.sendCommand('playback/setStreams', **params) + + # ------------------- + # Timeline Commands + def timeline(self): + """ Poll the current timeline and return the XML response. """ + return self.sendCommand('timeline/poll', wait=1) + + def isPlayingMedia(self, includePaused=False): + """ Returns True if any media is currently playing. + + Parameters: + includePaused (bool): Set True to treat currently paused items + as playing (optional; default True). + """ + for mediatype in self.timeline(): + if mediatype.get('state') == 'playing': + return True + if includePaused and mediatype.get('state') == 'paused': + return True + return False diff --git a/lib/plexapi/compat.py b/lib/plexapi/compat.py new file mode 100644 index 00000000..07b749d5 --- /dev/null +++ b/lib/plexapi/compat.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# Python 2/3 compatability +# Always try Py3 first +import os +from sys import version_info + +ustr = str +if version_info < (3,): + ustr = unicode + +try: + string_type = basestring +except NameError: + string_type = str + +try: + from urllib.parse import urlencode +except ImportError: + from urllib import urlencode + +try: + from urllib.parse import quote +except ImportError: + from urllib import quote + +try: + from urllib.parse import quote_plus +except ImportError: + from urllib import quote_plus + +try: + from urllib.parse import unquote +except ImportError: + from urllib import unquote + +try: + from configparser import ConfigParser +except ImportError: + from ConfigParser import ConfigParser + +try: + from xml.etree import cElementTree as ElementTree +except ImportError: + from xml.etree import ElementTree + + +def makedirs(name, mode=0o777, exist_ok=False): + """ Mimicks os.makedirs() from Python 3. """ + try: + os.makedirs(name, mode) + except OSError: + if not os.path.isdir(name) or not exist_ok: + raise diff --git a/lib/plexapi/config.py b/lib/plexapi/config.py new file mode 100644 index 00000000..20f9a96e --- /dev/null +++ b/lib/plexapi/config.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +import os +from collections import defaultdict +from plexapi.compat import ConfigParser + + +class PlexConfig(ConfigParser): + """ PlexAPI configuration object. Settings are stored in an INI file within the + user's home directory and can be overridden after importing plexapi by simply + setting the value. See the documentation section 'Configuration' for more + details on available options. + + Parameters: + path (str): Path of the configuration file to load. + """ + def __init__(self, path): + ConfigParser.__init__(self) + self.read(path) + self.data = self._asDict() + + def get(self, key, default=None, cast=None): + """ Returns the specified configuration value or if not found. + + Parameters: + key (str): Configuration variable to load in the format '
.'. + default: Default value to use if key not found. + cast (func): Cast the value to the specified type before returning. + """ + try: + # First: check environment variable is set + envkey = 'PLEXAPI_%s' % key.upper().replace('.', '_') + value = os.environ.get(envkey) + if value is None: + # Second: check the config file has attr + section, name = key.lower().split('.') + value = self.data.get(section, {}).get(name, default) + return cast(value) if cast else value + except: # noqa: E722 + return default + + def _asDict(self): + """ Returns all configuration values as a dictionary. """ + config = defaultdict(dict) + for section in self._sections: + for name, value in self._sections[section].items(): + if name != '__name__': + config[section.lower()][name.lower()] = value + return dict(config) + + +def reset_base_headers(): + """ Convenience function returns a dict of all base X-Plex-* headers for session requests. """ + import plexapi + return { + 'X-Plex-Platform': plexapi.X_PLEX_PLATFORM, + 'X-Plex-Platform-Version': plexapi.X_PLEX_PLATFORM_VERSION, + 'X-Plex-Provides': plexapi.X_PLEX_PROVIDES, + 'X-Plex-Product': plexapi.X_PLEX_PRODUCT, + 'X-Plex-Version': plexapi.X_PLEX_VERSION, + 'X-Plex-Device': plexapi.X_PLEX_DEVICE, + 'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME, + 'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER, + } diff --git a/lib/plexapi/exceptions.py b/lib/plexapi/exceptions.py new file mode 100644 index 00000000..45da9f23 --- /dev/null +++ b/lib/plexapi/exceptions.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- + + +class PlexApiException(Exception): + """ Base class for all PlexAPI exceptions. """ + pass + + +class BadRequest(PlexApiException): + """ An invalid request, generally a user error. """ + pass + + +class NotFound(PlexApiException): + """ Request media item or device is not found. """ + pass + + +class UnknownType(PlexApiException): + """ Unknown library type. """ + pass + + +class Unsupported(PlexApiException): + """ Unsupported client request. """ + pass + + +class Unauthorized(PlexApiException): + """ Invalid username or password. """ + pass diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py new file mode 100644 index 00000000..0afff3d3 --- /dev/null +++ b/lib/plexapi/library.py @@ -0,0 +1,716 @@ +# -*- coding: utf-8 -*- +from plexapi import X_PLEX_CONTAINER_SIZE, log, utils +from plexapi.base import PlexObject +from plexapi.compat import unquote, urlencode, quote_plus +from plexapi.media import MediaTag +from plexapi.exceptions import BadRequest, NotFound + + +class Library(PlexObject): + """ Represents a PlexServer library. This contains all sections of media defined + in your Plex server including video, shows and audio. + + Attributes: + key (str): '/library' + identifier (str): Unknown ('com.plexapp.plugins.library'). + mediaTagVersion (str): Unknown (/system/bundle/media/flags/) + server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to. + title1 (str): 'Plex Library' (not sure how useful this is). + title2 (str): Second title (this is blank on my setup). + """ + key = '/library' + + def _loadData(self, data): + self._data = data + self._sectionsByID = {} # cached Section UUIDs + self.identifier = data.attrib.get('identifier') + self.mediaTagVersion = data.attrib.get('mediaTagVersion') + self.title1 = data.attrib.get('title1') + self.title2 = data.attrib.get('title2') + + 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`. + """ + key = '/library/sections' + sections = [] + for elem in self._server.query(key): + 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. + """ + for section in self.sections(): + if section.title.lower() == title.lower(): + return section + raise NotFound('Invalid library section: %s' % title) + + def sectionByID(self, sectionID): + """ Returns the :class:`~plexapi.library.LibrarySection` that matches the specified sectionID. + + Parameters: + sectionID (str): ID of the section to return. + """ + if not self._sectionsByID or sectionID not in self._sectionsByID: + self.sections() + return self._sectionsByID[sectionID] + + def all(self, **kwargs): + """ Returns a list of all media from all library sections. + This may be a very large dataset to retrieve. + """ + 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. """ + return self.fetchItems('/library/onDeck') + + def recentlyAdded(self): + """ Returns a list of all media items recently added. """ + return self.fetchItems('/library/recentlyAdded') + + def search(self, title=None, libtype=None, **kwargs): + """ 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. + + Example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items + such as actor= 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. + """ + args = {} + if title: + args['title'] = title + if libtype: + args['type'] = utils.searchType(libtype) + for attr, value in kwargs.items(): + args[attr] = value + key = '/library/all%s' % utils.joinArgs(args) + return self.fetchItems(key) + + def cleanBundles(self): + """ Poster images and other metadata for items in your library are kept in "bundle" + packages. When you remove items from your library, these bundles aren't immediately + removed. Removing these old bundles can reduce the size of your install. By default, your + server will automatically clean up old bundles once a week as part of Scheduled Tasks. + """ + # TODO: Should this check the response for success or the correct mediaprefix? + self._server.query('/library/clean/bundles') + + def emptyTrash(self): + """ If a library has items in the Library Trash, use this option to empty the Trash. """ + for section in self.sections(): + section.emptyTrash() + + def optimize(self): + """ The Optimize option cleans up the server database from unused or fragmented data. + For example, if you have deleted or added an entire library or many items in a + library, you may like to optimize the database. + """ + self._server.query('/library/optimize') + + def update(self): + """ Scan this library for new items.""" + self._server.query('/library/sections/all/refresh') + + def cancelUpdate(self): + """ Cancel a library update. """ + key = '/library/sections/all/refresh' + self._server.query(key, method=self._server._session.delete) + + def refresh(self): + """ Forces a download of fresh media information from the internet. + This can take a long time. Any locked fields are not modified. + """ + self._server.query('/library/sections/all/refresh?force=1') + + def deleteMediaPreviews(self): + """ Delete the preview thumbnails for the all sections. This cannot be + undone. Recreating media preview files can take hours or even days. + """ + for section in self.sections(): + section.deleteMediaPreviews() + + def add(self, name='', type='', agent='', scanner='', location='', language='en', *args, **kwargs): + """ Simplified add for the most common options. + + Parameters: + name (str): Name of the library + agent (str): Example com.plexapp.agents.imdb + type (str): movie, show, # check me + location (str): /path/to/files + language (str): Two letter language fx en + kwargs (dict): Advanced options should be passed as a dict. where the id is the key. + + **Photo Preferences** + + * **agent** (str): com.plexapp.agents.none + * **enableAutoPhotoTags** (bool): Tag photos. Default value false. + * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. + * **includeInGlobal** (bool): Include in dashboard. Default value true. + * **scanner** (str): Plex Photo Scanner + + **Movie Preferences** + + * **agent** (str): com.plexapp.agents.none, com.plexapp.agents.imdb, com.plexapp.agents.themoviedb + * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. + * **enableCinemaTrailers** (bool): Enable Cinema Trailers. Default value true. + * **includeInGlobal** (bool): Include in dashboard. Default value true. + * **scanner** (str): Plex Movie Scanner, Plex Video Files Scanner + + **IMDB Movie Options** (com.plexapp.agents.imdb) + + * **title** (bool): Localized titles. Default value false. + * **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true. + * **only_trailers** (bool): Skip extras which aren't trailers. Default value false. + * **redband** (bool): Use red band (restricted audiences) trailers when available. Default value false. + * **native_subs** (bool): Include extras with subtitles in Library language. Default value false. + * **cast_list** (int): Cast List Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database. + * **ratings** (int): Ratings Source, Default value 0 Possible options: + 0:Rotten Tomatoes, 1:IMDb, 2:The Movie Database. + * **summary** (int): Plot Summary Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database. + * **country** (int): Default value 46 Possible options 0:Argentina, 1:Australia, 2:Austria, + 3:Belgium, 4:Belize, 5:Bolivia, 6:Brazil, 7:Canada, 8:Chile, 9:Colombia, 10:Costa Rica, + 11:Czech Republic, 12:Denmark, 13:Dominican Republic, 14:Ecuador, 15:El Salvador, + 16:France, 17:Germany, 18:Guatemala, 19:Honduras, 20:Hong Kong SAR, 21:Ireland, + 22:Italy, 23:Jamaica, 24:Korea, 25:Liechtenstein, 26:Luxembourg, 27:Mexico, 28:Netherlands, + 29:New Zealand, 30:Nicaragua, 31:Panama, 32:Paraguay, 33:Peru, 34:Portugal, + 35:Peoples Republic of China, 36:Puerto Rico, 37:Russia, 38:Singapore, 39:South Africa, + 40:Spain, 41:Sweden, 42:Switzerland, 43:Taiwan, 44:Trinidad, 45:United Kingdom, + 46:United States, 47:Uruguay, 48:Venezuela. + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **usage** (bool): Send anonymous usage data to Plex. Default value true. + + **TheMovieDB Movie Options** (com.plexapp.agents.themoviedb) + + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **country** (int): Country (used for release date and content rating). Default value 47 Possible + options 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, 6:Bolivia, 7:Brazil, 8:Canada, + 9:Chile, 10:Colombia, 11:Costa Rica, 12:Czech Republic, 13:Denmark, 14:Dominican Republic, 15:Ecuador, + 16:El Salvador, 17:France, 18:Germany, 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland, + 23:Italy, 24:Jamaica, 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands, + 30:New Zealand, 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal, + 36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, 40:South Africa, 41:Spain, + 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, 46:United Kingdom, 47:United States, 48:Uruguay, + 49:Venezuela. + + **Show Preferences** + + * **agent** (str): com.plexapp.agents.none, com.plexapp.agents.thetvdb, com.plexapp.agents.themoviedb + * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. + * **episodeSort** (int): Episode order. Default -1 Possible options: 0:Oldest first, 1:Newest first. + * **flattenSeasons** (int): Seasons. Default value 0 Possible options: 0:Show,1:Hide. + * **includeInGlobal** (bool): Include in dashboard. Default value true. + * **scanner** (str): Plex Series Scanner + + **TheTVDB Show Options** (com.plexapp.agents.thetvdb) + + * **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true. + * **native_subs** (bool): Include extras with subtitles in Library language. Default value false. + + **TheMovieDB Show Options** (com.plexapp.agents.themoviedb) + + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **country** (int): Country (used for release date and content rating). Default value 47 options + 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, 6:Bolivia, 7:Brazil, 8:Canada, 9:Chile, + 10:Colombia, 11:Costa Rica, 12:Czech Republic, 13:Denmark, 14:Dominican Republic, 15:Ecuador, + 16:El Salvador, 17:France, 18:Germany, 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland, + 23:Italy, 24:Jamaica, 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands, + 30:New Zealand, 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal, + 36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, 40:South Africa, + 41:Spain, 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, 46:United Kingdom, 47:United States, + 48:Uruguay, 49:Venezuela. + + **Other Video Preferences** + + * **agent** (str): com.plexapp.agents.none, com.plexapp.agents.imdb, com.plexapp.agents.themoviedb + * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. + * **enableCinemaTrailers** (bool): Enable Cinema Trailers. Default value true. + * **includeInGlobal** (bool): Include in dashboard. Default value true. + * **scanner** (str): Plex Movie Scanner, Plex Video Files Scanner + + **IMDB Other Video Options** (com.plexapp.agents.imdb) + + * **title** (bool): Localized titles. Default value false. + * **extras** (bool): Find trailers and extras automatically (Plex Pass required). Default value true. + * **only_trailers** (bool): Skip extras which aren't trailers. Default value false. + * **redband** (bool): Use red band (restricted audiences) trailers when available. Default value false. + * **native_subs** (bool): Include extras with subtitles in Library language. Default value false. + * **cast_list** (int): Cast List Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database. + * **ratings** (int): Ratings Source Default value 0 Possible options: + 0:Rotten Tomatoes,1:IMDb,2:The Movie Database. + * **summary** (int): Plot Summary Source: Default value 1 Possible options: 0:IMDb,1:The Movie Database. + * **country** (int): Country: Default value 46 Possible options: 0:Argentina, 1:Australia, 2:Austria, + 3:Belgium, 4:Belize, 5:Bolivia, 6:Brazil, 7:Canada, 8:Chile, 9:Colombia, 10:Costa Rica, + 11:Czech Republic, 12:Denmark, 13:Dominican Republic, 14:Ecuador, 15:El Salvador, 16:France, + 17:Germany, 18:Guatemala, 19:Honduras, 20:Hong Kong SAR, 21:Ireland, 22:Italy, 23:Jamaica, + 24:Korea, 25:Liechtenstein, 26:Luxembourg, 27:Mexico, 28:Netherlands, 29:New Zealand, 30:Nicaragua, + 31:Panama, 32:Paraguay, 33:Peru, 34:Portugal, 35:Peoples Republic of China, 36:Puerto Rico, + 37:Russia, 38:Singapore, 39:South Africa, 40:Spain, 41:Sweden, 42:Switzerland, 43:Taiwan, 44:Trinidad, + 45:United Kingdom, 46:United States, 47:Uruguay, 48:Venezuela. + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **usage** (bool): Send anonymous usage data to Plex. Default value true. + + **TheMovieDB Other Video Options** (com.plexapp.agents.themoviedb) + + * **collections** (bool): Use collection info from The Movie Database. Default value false. + * **localart** (bool): Prefer artwork based on library language. Default value true. + * **adult** (bool): Include adult content. Default value false. + * **country** (int): Country (used for release date and content rating). Default + value 47 Possible options 0:, 1:Argentina, 2:Australia, 3:Austria, 4:Belgium, 5:Belize, + 6:Bolivia, 7:Brazil, 8:Canada, 9:Chile, 10:Colombia, 11:Costa Rica, 12:Czech Republic, + 13:Denmark, 14:Dominican Republic, 15:Ecuador, 16:El Salvador, 17:France, 18:Germany, + 19:Guatemala, 20:Honduras, 21:Hong Kong SAR, 22:Ireland, 23:Italy, 24:Jamaica, + 25:Korea, 26:Liechtenstein, 27:Luxembourg, 28:Mexico, 29:Netherlands, 30:New Zealand, + 31:Nicaragua, 32:Panama, 33:Paraguay, 34:Peru, 35:Portugal, + 36:Peoples Republic of China, 37:Puerto Rico, 38:Russia, 39:Singapore, + 40:South Africa, 41:Spain, 42:Sweden, 43:Switzerland, 44:Taiwan, 45:Trinidad, + 46:United Kingdom, 47:United States, 48:Uruguay, 49:Venezuela. + """ + part = '/library/sections?name=%s&type=%s&agent=%s&scanner=%s&language=%s&location=%s' % ( + quote_plus(name), type, agent, quote_plus(scanner), language, quote_plus(location)) # noqa E126 + if kwargs: + part += urlencode(kwargs) + return self._server.query(part, method=self._server._session.post) + + +class LibrarySection(PlexObject): + """ Base class for a single library section. + + Attributes: + ALLOWED_FILTERS (tuple): () + ALLOWED_SORT (tuple): () + BOOLEAN_FILTERS (tuple): ('unwatched', 'duplicate') + server (:class:`~plexapi.server.PlexServer`): Server this client is connected to. + initpath (str): Path requested when building this object. + agent (str): Unknown (com.plexapp.agents.imdb, etc) + allowSync (bool): True if you allow syncing content from this section. + art (str): Wallpaper artwork used to respresent this section. + composite (str): Composit image used to represent this section. + createdAt (datetime): Datetime this library section was created. + filters (str): Unknown + key (str): Key (or ID) of this library section. + language (str): Language represented in this section (en, xn, etc). + locations (str): Paths on disk where section content is stored. + refreshing (str): True if this section is currently being refreshed. + scanner (str): Internal scanner used to find media (Plex Movie Scanner, Plex Premium Music Scanner, etc.) + thumb (str): Thumbnail image used to represent this section. + title (str): Title of this section. + type (str): Type of content section represents (movie, artist, photo, show). + updatedAt (datetime): Datetime this library section was last updated. + uuid (str): Unique id for this section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63) + """ + ALLOWED_FILTERS = () + ALLOWED_SORT = () + BOOLEAN_FILTERS = ('unwatched', 'duplicate') + + def _loadData(self, data): + self._data = data + 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') + self.key = data.attrib.get('key') # invalid key from plex + self.language = data.attrib.get('language') + self.locations = self.listAttrs(data, 'path', etag='Location') + 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') + + def delete(self): + """ Delete a library section. """ + try: + return self._server.query('/library/sections/%s' % self.key, method=self._server._session.delete) + except BadRequest: # pragma: no cover + msg = 'Failed to delete library %s' % self.key + msg += 'You may need to allow this permission in your Plex settings.' + log.error(msg) + raise + + def edit(self, **kwargs): + """ Edit a library (Note: agent is required). See :class:`~plexapi.library.Library` for example usage. + + Parameters: + kwargs (dict): Dict of settings to edit. + """ + part = '/library/sections/%s?%s' % (self.key, urlencode(kwargs)) + self._server.query(part, method=self._server._session.put) + + # Reload this way since the self.key dont have a full path, but is simply a id. + for s in self._server.library.sections(): + if s.key == self.key: + return s + + def get(self, title): + """ Returns the media item with the specified title. + + Parameters: + title (str): Title of the item to return. + """ + key = '/library/sections/%s/all' % self.key + return self.fetchItem(key, title__iexact=title) + + def all(self, **kwargs): + """ Returns a list of media from this library section. """ + key = '/library/sections/%s/all' % self.key + return self.fetchItems(key, **kwargs) + + def onDeck(self): + """ Returns a list of media items on deck from this library section. """ + key = '/library/sections/%s/onDeck' % self.key + return self.fetchItems(key) + + def recentlyAdded(self, maxresults=50): + """ Returns a list of media items recently added from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.search(sort='addedAt:desc', maxresults=maxresults) + + def analyze(self): + """ Run an analysis on all of the items in this library section. See + See :func:`~plexapi.base.PlexPartialObject.analyze` for more details. + """ + key = '/library/sections/%s/analyze' % self.key + self._server.query(key, method=self._server._session.put) + + def emptyTrash(self): + """ If a section has items in the Trash, use this option to empty the Trash. """ + key = '/library/sections/%s/emptyTrash' % self.key + self._server.query(key, method=self._server._session.put) + + def update(self): + """ Scan this section for new media. """ + key = '/library/sections/%s/refresh' % self.key + self._server.query(key) + + def cancelUpdate(self): + """ Cancel update of this Library Section. """ + key = '/library/sections/%s/refresh' % self.key + self._server.query(key, method=self._server._session.delete) + + def refresh(self): + """ Forces a download of fresh media information from the internet. + This can take a long time. Any locked fields are not modified. + """ + key = '/library/sections/%s/refresh?force=1' % self.key + self._server.query(key) + + def deleteMediaPreviews(self): + """ Delete the preview thumbnails for items in this library. This cannot + be undone. Recreating media preview files can take hours or even days. + """ + key = '/library/sections/%s/indexes' % self.key + self._server.query(key, method=self._server._session.delete) + + def listChoices(self, category, libtype=None, **kwargs): + """ Returns a list of :class:`~plexapi.library.FilterChoice` objects for the + specified category and libtype. kwargs can be any of the same kwargs in + :func:`plexapi.library.LibraySection.search()` to help narrow down the choices + to only those that matter in your current context. + + Parameters: + category (str): Category to list choices for (genre, contentRating, etc). + libtype (int): Library type of item filter. + **kwargs (dict): Additional kwargs to narrow down the choices. + + Raises: + :class:`~plexapi.exceptions.BadRequest`: Cannot include kwarg equal to specified category. + """ + # TODO: Should this be moved to base? + if category in kwargs: + raise BadRequest('Cannot include kwarg equal to specified category: %s' % category) + args = {} + for subcategory, value in kwargs.items(): + args[category] = self._cleanSearchFilter(subcategory, value) + 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, 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 + in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first + 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. + + Parameters: + title (str): General string query to search for (optional). + sort (str): column:dir; column can be any of {addedAt, originallyAvailableAt, lastViewedAt, + titleSort, rating, mediaHeight, duration}. dir can be asc or desc (optional). + maxresults (int): Only return the specified number of results (optional). + libtype (str): Filter results to a spcifiec libtype (movie, show, episode, artist, + album, track; optional). + **kwargs (dict): 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] + * 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] + * 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): + key = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args)) + subresults = self.fetchItems(key) + results += subresults[:maxresults - len(results)] + args['X-Plex-Container-Start'] += args['X-Plex-Container-Size'] + return results + + def _cleanSearchFilter(self, category, value, libtype=None): + # check a few things before we begin + 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] + # convert list of values to list of keys or ids + result = set() + choices = self.listChoices(category, libtype) + lookup = {c.title.lower(): unquote(unquote(c.key)) for c in choices} + allowed = set(c.key for c in choices) + for item in value: + item = str((item.id or item.tag) 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) + 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) + + +class MovieSection(LibrarySection): + """ Represents a :class:`~plexapi.library.LibrarySection` section containing movies. + + Attributes: + ALLOWED_FILTERS (list): List of allowed search filters. ('unwatched', + 'duplicate', 'year', 'decade', 'genre', 'contentRating', 'collection', + 'director', 'actor', 'country', 'studio', 'resolution', 'guid', 'label') + ALLOWED_SORT (list): 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', + 'guid', 'label') + ALLOWED_SORT = ('addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating', + 'mediaHeight', 'duration') + TAG = 'Directory' + TYPE = 'movie' + + +class ShowSection(LibrarySection): + """ Represents a :class:`~plexapi.library.LibrarySection` section containing tv shows. + + Attributes: + ALLOWED_FILTERS (list): List of allowed search filters. ('unwatched', + 'year', 'genre', 'contentRating', 'network', 'collection', 'guid', 'label') + ALLOWED_SORT (list): 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', + 'guid', 'duplicate', 'label') + ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort', + 'rating', 'unwatched') + TAG = 'Directory' + TYPE = 'show' + + def searchShows(self, **kwargs): + """ Search for a show. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ + return self.search(libtype='show', **kwargs) + + def searchEpisodes(self, **kwargs): + """ Search for an episode. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ + return self.search(libtype='episode', **kwargs) + + def recentlyAdded(self, libtype='episode', maxresults=50): + """ Returns a list of recently added episodes from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.search(sort='addedAt:desc', libtype=libtype, maxresults=maxresults) + + +class MusicSection(LibrarySection): + """ Represents a :class:`~plexapi.library.LibrarySection` section containing music artists. + + Attributes: + ALLOWED_FILTERS (list): List of allowed search filters. ('genre', + 'country', 'collection') + ALLOWED_SORT (list): List of allowed sorting keys. ('addedAt', + 'lastViewedAt', 'viewCount', 'titleSort') + TAG (str): 'Directory' + TYPE (str): 'artist' + """ + ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood') + ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort') + TAG = 'Directory' + TYPE = 'artist' + + def albums(self): + """ Returns a list of :class:`~plexapi.audio.Album` objects in this section. """ + key = '/library/sections/%s/albums' % self.key + return self.fetchItems(key) + + def searchArtists(self, **kwargs): + """ Search for an artist. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ + return self.search(libtype='artist', **kwargs) + + def searchAlbums(self, **kwargs): + """ Search for an album. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ + return self.search(libtype='album', **kwargs) + + def searchTracks(self, **kwargs): + """ Search for a track. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ + return self.search(libtype='track', **kwargs) + + +class PhotoSection(LibrarySection): + """ Represents a :class:`~plexapi.library.LibrarySection` section containing photos. + + Attributes: + ALLOWED_FILTERS (list): List of allowed search filters. ('all', 'iso', + 'make', 'lens', 'aperture', 'exposure') + ALLOWED_SORT (list): List of allowed sorting keys. ('addedAt') + TAG (str): 'Directory' + TYPE (str): 'photo' + """ + ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure') + ALLOWED_SORT = ('addedAt',) + TAG = 'Directory' + TYPE = 'photo' + + def searchAlbums(self, title, **kwargs): + """ Search for an album. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ + key = '/library/sections/%s/all?type=14' % self.key + return self.fetchItems(key, title=title) + + def searchPhotos(self, title, **kwargs): + """ Search for a photo. See :func:`~plexapi.library.LibrarySection.search()` for usage. """ + key = '/library/sections/%s/all?type=13' % self.key + return self.fetchItems(key, title=title) + + +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 + :func:`~plexapi.library.LibrarySection.listChoices()`. + + Attributes: + TAG (str): 'Directory' + server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to. + initpath (str): Relative path requested when retrieving specified `data` (optional). + fastKey (str): API path to quickly list all items in this filter + (/library/sections/
/all?genre=) + key (str): Short key (id) of this filter option (used ad in fastKey above). + thumb (str): Thumbnail used to represent this filter option. + title (str): Human readable name for this filter option. + type (str): Filter type (genre, contentRating, etc). + """ + TAG = 'Directory' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.fastKey = data.attrib.get('fastKey') + self.key = data.attrib.get('key') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + + +@utils.registerPlexObject +class Hub(PlexObject): + """ Represents a single Hub (or category) in the PlexServer search. + + Attributes: + TAG (str): 'Hub' + hubIdentifier (str): Unknown. + size (int): Number of items found. + title (str): Title of this Hub. + type (str): Type of items in the Hub. + items (str): List of items in the Hub. + """ + TAG = 'Hub' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.hubIdentifier = data.attrib.get('hubIdentifier') + self.size = utils.cast(int, data.attrib.get('size')) + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.items = self.findItems(data) + + def __len__(self): + return self.size diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py new file mode 100644 index 00000000..81f7d6f9 --- /dev/null +++ b/lib/plexapi/media.py @@ -0,0 +1,504 @@ +# -*- coding: utf-8 -*- +from plexapi import log, 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. + + Attributes: + TAG (str): 'Media' + server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from. + initpath (str): Relative path requested when retrieving specified data. + video (str): Video this media belongs to. + aspectRatio (float): Aspect ratio of the video (ex: 2.35). + audioChannels (int): Number of audio channels for this video (ex: 6). + audioCodec (str): Audio codec used within the video (ex: ac3). + bitrate (int): Bitrate of the video (ex: 1624) + container (str): Container this video is in (ex: avi). + duration (int): Length of the video in milliseconds (ex: 6990483). + height (int): Height of the video in pixels (ex: 256). + id (int): Plex ID of this media item (ex: 46184). + has64bitOffsets (bool): True if video has 64 bit offsets (?). + optimizedForStreaming (bool): True if video is optimized for streaming. + videoCodec (str): Video codec used within the video (ex: ac3). + videoFrameRate (str): Video frame rate (ex: 24p). + videoResolution (str): Video resolution (ex: sd). + width (int): Width of the video in pixels (ex: 608). + parts (list<:class:`~plexapi.media.MediaPart`>): List of MediaParts in this video. + """ + TAG = 'Media' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.aspectRatio = cast(float, data.attrib.get('aspectRatio')) + self.audioChannels = cast(int, data.attrib.get('audioChannels')) + self.audioCodec = data.attrib.get('audioCodec') + self.bitrate = cast(int, data.attrib.get('bitrate')) + self.container = data.attrib.get('container') + self.duration = cast(int, data.attrib.get('duration')) + self.height = cast(int, data.attrib.get('height')) + self.id = cast(int, data.attrib.get('id')) + self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets')) + self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming')) + self.videoCodec = data.attrib.get('videoCodec') + self.videoFrameRate = data.attrib.get('videoFrameRate') + self.videoResolution = data.attrib.get('videoResolution') + self.width = cast(int, data.attrib.get('width')) + self.parts = self.findItems(data, MediaPart) + + def delete(self): + part = self._initpath + '/media/%s' % self.id + try: + return self._server.query(part, method=self._server._session.delete) + except BadRequest: + log.error("Failed to delete %s. This could be because you havn't allowed " + "items to be deleted" % part) + raise + + +@utils.registerPlexObject +class MediaPart(PlexObject): + """ Represents a single media part (often a single file) for the media this belongs to. + + Attributes: + TAG (str): 'Part' + server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from. + initpath (str): Relative path requested when retrieving specified data. + media (:class:`~plexapi.media.Media`): Media object this part belongs to. + container (str): Container type of this media part (ex: avi). + duration (int): Length of this media part in milliseconds. + file (str): Path to this file on disk (ex: /media/Movies/Cars.(2006)/Cars.cd2.avi) + id (int): Unique ID of this media part. + indexes (str, None): None or SD. + key (str): Key used to access this media part (ex: /library/parts/46618/1389985872/file.avi). + size (int): Size of this file in bytes (ex: 733884416). + streams (list<:class:`~plexapi.media.MediaPartStream`>): List of streams in this media part. + """ + TAG = 'Part' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.container = data.attrib.get('container') + self.duration = cast(int, data.attrib.get('duration')) + self.file = data.attrib.get('file') + self.id = cast(int, data.attrib.get('id')) + self.indexes = data.attrib.get('indexes') + self.key = data.attrib.get('key') + self.size = cast(int, data.attrib.get('size')) + self.streams = self._buildStreams(data) + + def _buildStreams(self, data): + streams = [] + for elem in data: + for cls in (VideoStream, AudioStream, SubtitleStream): + if elem.attrib.get('streamType') == str(cls.STREAMTYPE): + streams.append(cls(self._server, elem, self._initpath)) + return streams + + def videoStreams(self): + """ Returns a list of :class:`~plexapi.media.VideoStream` objects in this MediaPart. """ + return [stream for stream in self.streams if stream.streamType == VideoStream.STREAMTYPE] + + def audioStreams(self): + """ Returns a list of :class:`~plexapi.media.AudioStream` objects in this MediaPart. """ + return [stream for stream in self.streams if stream.streamType == AudioStream.STREAMTYPE] + + def subtitleStreams(self): + """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """ + return [stream for stream in self.streams if stream.streamType == SubtitleStream.STREAMTYPE] + + +class MediaPartStream(PlexObject): + """ Base class for media streams. These consist of video, audio and subtitles. + + Attributes: + server (:class:`~plexapi.server.PlexServer`): PlexServer object this is from. + initpath (str): Relative path requested when retrieving specified data. + part (:class:`~plexapi.media.MediaPart`): Media part this stream belongs to. + codec (str): Codec of this stream (ex: srt, ac3, mpeg4). + codecID (str): Codec ID (ex: XVID). + id (int): Unique stream ID on this server. + index (int): Unknown + language (str): Stream language (ex: English, ไทย). + languageCode (str): Ascii code for language (ex: eng, tha). + selected (bool): True if this stream is selected. + streamType (int): Stream type (1=:class:`~plexapi.media.VideoStream`, + 2=:class:`~plexapi.media.AudioStream`, 3=:class:`~plexapi.media.SubtitleStream`). + type (int): Alias for streamType. + """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.codec = data.attrib.get('codec') + self.codecID = data.attrib.get('codecID') + self.id = cast(int, data.attrib.get('id')) + self.index = cast(int, data.attrib.get('index', '-1')) + self.language = data.attrib.get('language') + self.languageCode = data.attrib.get('languageCode') + self.selected = cast(bool, data.attrib.get('selected', '0')) + self.streamType = cast(int, data.attrib.get('streamType')) + self.type = cast(int, data.attrib.get('streamType')) + + @staticmethod + def parse(server, data, initpath): # pragma: no cover seems to be dead code. + """ Factory method returns a new MediaPartStream from xml data. """ + STREAMCLS = {1: VideoStream, 2: AudioStream, 3: SubtitleStream} + stype = cast(int, data.attrib.get('streamType')) + cls = STREAMCLS.get(stype, MediaPartStream) + return cls(server, data, initpath) + + +@utils.registerPlexObject +class VideoStream(MediaPartStream): + """ Respresents a video stream within a :class:`~plexapi.media.MediaPart`. + + Attributes: + TAG (str): 'Stream' + STREAMTYPE (int): 1 + bitDepth (int): Bit depth (ex: 8). + bitrate (int): Bitrate (ex: 1169) + cabac (int): Unknown + chromaSubsampling (str): Chroma Subsampling (ex: 4:2:0). + colorSpace (str): Unknown + duration (int): Duration of video stream in milliseconds. + frameRate (float): Frame rate (ex: 23.976) + frameRateMode (str): Unknown + hasScallingMatrix (bool): True if video stream has a scaling matrix. + height (int): Height of video stream. + level (int): Videl stream level (?). + profile (str): Video stream profile (ex: asp). + refFrames (int): Unknown + scanType (str): Video stream scan type (ex: progressive). + title (str): Title of this video stream. + width (int): Width of video stream. + """ + TAG = 'Stream' + STREAMTYPE = 1 + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(VideoStream, self)._loadData(data) + self.bitDepth = cast(int, data.attrib.get('bitDepth')) + self.bitrate = cast(int, data.attrib.get('bitrate')) + self.cabac = cast(int, data.attrib.get('cabac')) + self.chromaSubsampling = data.attrib.get('chromaSubsampling') + self.colorSpace = data.attrib.get('colorSpace') + self.duration = cast(int, data.attrib.get('duration')) + self.frameRate = cast(float, data.attrib.get('frameRate')) + self.frameRateMode = data.attrib.get('frameRateMode') + self.hasScallingMatrix = cast(bool, data.attrib.get('hasScallingMatrix')) + self.height = cast(int, data.attrib.get('height')) + self.level = cast(int, data.attrib.get('level')) + self.profile = data.attrib.get('profile') + self.refFrames = cast(int, data.attrib.get('refFrames')) + self.scanType = data.attrib.get('scanType') + self.title = data.attrib.get('title') + self.width = cast(int, data.attrib.get('width')) + + +@utils.registerPlexObject +class AudioStream(MediaPartStream): + """ Respresents a audio stream within a :class:`~plexapi.media.MediaPart`. + + Attributes: + TAG (str): 'Stream' + STREAMTYPE (int): 2 + audioChannelLayout (str): Audio channel layout (ex: 5.1(side)). + bitDepth (int): Bit depth (ex: 16). + bitrate (int): Audio bitrate (ex: 448). + bitrateMode (str): Bitrate mode (ex: cbr). + channels (int): number of channels in this stream (ex: 6). + dialogNorm (int): Unknown (ex: -27). + duration (int): Duration of audio stream in milliseconds. + samplingRate (int): Sampling rate (ex: xxx) + title (str): Title of this audio stream. + """ + TAG = 'Stream' + STREAMTYPE = 2 + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(AudioStream, self)._loadData(data) + self.audioChannelLayout = data.attrib.get('audioChannelLayout') + self.bitDepth = cast(int, data.attrib.get('bitDepth')) + self.bitrate = cast(int, data.attrib.get('bitrate')) + self.bitrateMode = data.attrib.get('bitrateMode') + self.channels = cast(int, data.attrib.get('channels')) + self.dialogNorm = cast(int, data.attrib.get('dialogNorm')) + self.duration = cast(int, data.attrib.get('duration')) + self.samplingRate = cast(int, data.attrib.get('samplingRate')) + self.title = data.attrib.get('title') + + +@utils.registerPlexObject +class SubtitleStream(MediaPartStream): + """ Respresents a audio stream within a :class:`~plexapi.media.MediaPart`. + + Attributes: + TAG (str): 'Stream' + STREAMTYPE (int): 3 + format (str): Subtitle format (ex: srt). + key (str): Key of this subtitle stream (ex: /library/streams/212284). + title (str): Title of this subtitle stream. + """ + TAG = 'Stream' + STREAMTYPE = 3 + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(SubtitleStream, self)._loadData(data) + self.format = data.attrib.get('format') + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + + +@utils.registerPlexObject +class Session(PlexObject): + """ Represents a current session. """ + TAG = 'Session' + + def _loadData(self, data): + self.id = data.attrib.get('id') + self.bandwidth = utils.cast(int, data.attrib.get('bandwidth')) + self.location = data.attrib.get('location') + + +@utils.registerPlexObject +class TranscodeSession(PlexObject): + """ Represents a current transcode session. + + Attributes: + TAG (str): 'TranscodeSession' + TODO: Document this. + """ + TAG = 'TranscodeSession' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.audioChannels = cast(int, data.attrib.get('audioChannels')) + self.audioCodec = data.attrib.get('audioCodec') + self.audioDecision = data.attrib.get('audioDecision') + self.container = data.attrib.get('container') + self.context = data.attrib.get('context') + self.duration = cast(int, data.attrib.get('duration')) + self.height = cast(int, data.attrib.get('height')) + self.key = data.attrib.get('key') + self.progress = cast(float, data.attrib.get('progress')) + self.protocol = data.attrib.get('protocol') + self.remaining = cast(int, data.attrib.get('remaining')) + self.speed = cast(int, data.attrib.get('speed')) + self.throttled = cast(int, data.attrib.get('throttled')) + self.sourceVideoCodec = data.attrib.get('sourceVideoCodec') + self.videoCodec = data.attrib.get('videoCodec') + self.videoDecision = data.attrib.get('videoDecision') + self.width = cast(int, data.attrib.get('width')) + + +class MediaTag(PlexObject): + """ Base class for media tags used for filtering and searching your library + items or navigating the metadata of media items in your library. Tags are + the construct used for things such as Country, Director, Genre, etc. + + Attributes: + server (:class:`~plexapi.server.PlexServer`): Server this client is connected to. + id (id): Tag ID (This seems meaningless except to use it as a unique id). + role (str): Unknown + tag (str): Name of the tag. This will be Animation, SciFi etc for Genres. The name of + person for Directors and Roles (ex: Animation, Stephen Graham, etc). + : Attributes only applicable in search results from + PlexServer :func:`~plexapi.server.PlexServer.search()`. They provide details of which + library section the tag was found as well as the url to dig deeper into the results. + + * key (str): API URL to dig deeper into this tag (ex: /library/sections/1/all?actor=9081). + * librarySectionID (int): Section ID this tag was generated from. + * librarySectionTitle (str): Library section title this tag was found. + * librarySectionType (str): Media type of the library section this tag was found. + * tagType (int): Tag type ID. + * thumb (str): URL to thumbnail image. + """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.id = cast(int, data.attrib.get('id')) + self.role = data.attrib.get('role') + self.tag = data.attrib.get('tag') + # additional attributes only from hub search + self.key = data.attrib.get('key') + self.librarySectionID = cast(int, data.attrib.get('librarySectionID')) + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.librarySectionType = data.attrib.get('librarySectionType') + self.tagType = cast(int, data.attrib.get('tagType')) + self.thumb = data.attrib.get('thumb') + + def items(self, *args, **kwargs): + """ Return the list of items within this tag. This function is only applicable + in search results from PlexServer :func:`~plexapi.server.PlexServer.search()`. + """ + if not self.key: + raise BadRequest('Key is not defined for this tag: %s' % self.tag) + return self.fetchItems(self.key) + + +@utils.registerPlexObject +class Collection(MediaTag): + """ Represents a single Collection media tag. + + Attributes: + TAG (str): 'Collection' + FILTER (str): 'collection' + """ + TAG = 'Collection' + FILTER = 'collection' + + +@utils.registerPlexObject +class Label(MediaTag): + """ Represents a single label media tag. + + Attributes: + TAG (str): 'label' + FILTER (str): 'label' + """ + TAG = 'Label' + FILTER = 'label' + + +@utils.registerPlexObject +class Country(MediaTag): + """ Represents a single Country media tag. + + Attributes: + TAG (str): 'Country' + FILTER (str): 'country' + """ + TAG = 'Country' + FILTER = 'country' + + +@utils.registerPlexObject +class Director(MediaTag): + """ Represents a single Director media tag. + + Attributes: + TAG (str): 'Director' + FILTER (str): 'director' + """ + TAG = 'Director' + FILTER = 'director' + + +@utils.registerPlexObject +class Genre(MediaTag): + """ Represents a single Genre media tag. + + Attributes: + TAG (str): 'Genre' + FILTER (str): 'genre' + """ + TAG = 'Genre' + FILTER = 'genre' + + +@utils.registerPlexObject +class Mood(MediaTag): + """ Represents a single Mood media tag. + + Attributes: + TAG (str): 'Mood' + FILTER (str): 'mood' + """ + TAG = 'Mood' + FILTER = 'mood' + + +@utils.registerPlexObject +class Producer(MediaTag): + """ Represents a single Producer media tag. + + Attributes: + TAG (str): 'Producer' + FILTER (str): 'producer' + """ + TAG = 'Producer' + FILTER = 'producer' + + +@utils.registerPlexObject +class Role(MediaTag): + """ Represents a single Role (actor/actress) media tag. + + Attributes: + TAG (str): 'Role' + FILTER (str): 'role' + """ + TAG = 'Role' + FILTER = 'role' + + +@utils.registerPlexObject +class Similar(MediaTag): + """ Represents a single Similar media tag. + + Attributes: + TAG (str): 'Similar' + FILTER (str): 'similar' + """ + TAG = 'Similar' + FILTER = 'similar' + + +@utils.registerPlexObject +class Writer(MediaTag): + """ Represents a single Writer media tag. + + Attributes: + TAG (str): 'Writer' + FILTER (str): 'writer' + """ + TAG = 'Writer' + FILTER = 'writer' + + +@utils.registerPlexObject +class Chapter(PlexObject): + """ Represents a single Writer media tag. + + Attributes: + TAG (str): 'Chapter' + """ + TAG = 'Chapter' + + def _loadData(self, data): + self._data = data + self.id = cast(int, data.attrib.get('id', 0)) + self.filter = data.attrib.get('filter') # I couldn't filter on it anyways + self.tag = data.attrib.get('tag') + self.title = self.tag + self.index = cast(int, data.attrib.get('index')) + self.start = cast(int, data.attrib.get('startTimeOffset')) + self.end = cast(int, data.attrib.get('endTimeOffset')) + + +@utils.registerPlexObject +class Field(PlexObject): + """ Represents a single Field. + + Attributes: + TAG (str): 'Field' + """ + TAG = 'Field' + + def _loadData(self, data): + self._data = data + self.name = data.attrib.get('name') + self.locked = cast(bool, data.attrib.get('locked')) diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py new file mode 100644 index 00000000..bec46876 --- /dev/null +++ b/lib/plexapi/myplex.py @@ -0,0 +1,727 @@ +# -*- coding: utf-8 -*- +import copy +import requests +import time +from requests.status_codes import _codes as codes +from plexapi import BASE_HEADERS, CONFIG, TIMEOUT +from plexapi import log, logfilter, utils +from plexapi.base import PlexObject +from plexapi.exceptions import BadRequest, NotFound +from plexapi.client import PlexClient +from plexapi.compat import ElementTree +from plexapi.library import LibrarySection +from plexapi.server import PlexServer +from plexapi.utils import joinArgs + + +class MyPlexAccount(PlexObject): + """ MyPlex account and profile information. This object represents the data found Account on + the myplex.tv servers at the url https://plex.tv/users/account. You may create this object + directly by passing in your username & password (or token). There is also a convenience + method provided at :class:`~plexapi.server.PlexServer.myPlexAccount()` which will create + and return this object. + + Parameters: + username (str): Your MyPlex username. + password (str): Your MyPlex password. + session (requests.Session, optional): Use your own session object if you want to + cache the http responses from PMS + timeout (int): timeout in seconds on initial connect to myplex (default config.TIMEOUT). + + Attributes: + SIGNIN (str): 'https://plex.tv/users/sign_in.xml' + key (str): 'https://plex.tv/users/account' + authenticationToken (str): Unknown. + certificateVersion (str): Unknown. + cloudSyncDevice (str): Unknown. + email (str): Your current Plex email address. + entitlements (List): List of devices your allowed to use with this account. + guest (bool): Unknown. + home (bool): Unknown. + homeSize (int): Unknown. + id (str): Your Plex account ID. + locale (str): Your Plex locale + mailing_list_status (str): Your current mailing list status. + maxHomeSize (int): Unknown. + queueEmail (str): Email address to add items to your `Watch Later` queue. + queueUid (str): Unknown. + restricted (bool): Unknown. + roles: (List) Lit of account roles. Plexpass membership listed here. + scrobbleTypes (str): Description + secure (bool): Description + subscriptionActive (bool): True if your subsctiption is active. + subscriptionFeatures: (List) List of features allowed on your subscription. + subscriptionPlan (str): Name of subscription plan. + subscriptionStatus (str): String representation of `subscriptionActive`. + thumb (str): URL of your account thumbnail. + title (str): Unknown. - Looks like an alias for `username`. + username (str): Your account username. + uuid (str): Unknown. + _token (str): Token used to access this client. + _session (obj): Requests session object used to access this client. + """ + FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data + FRIENDSERVERS = 'https://plex.tv/api/servers/{machineId}/shared_servers/{serverId}' # put with data + PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get + FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete + REMOVEINVITE = 'https://plex.tv/api/invites/requested/{userId}?friend=0&server=1&home=0' # delete + REQUESTED = 'https://plex.tv/api/invites/requested' # get + REQUESTS = 'https://plex.tv/api/invites/requests' # get + SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth + WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data + # Key may someday switch to the following url. For now the current value works. + # https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId} + key = 'https://plex.tv/users/account' + + def __init__(self, username=None, password=None, token=None, session=None, timeout=None): + self._token = token + self._session = session or requests.Session() + data, initpath = self._signin(username, password, timeout) + super(MyPlexAccount, self).__init__(self, data, initpath) + + def _signin(self, username, password, timeout): + if self._token: + return self.query(self.key), self.key + username = username or CONFIG.get('auth.myplex_username') + password = password or CONFIG.get('auth.myplex_password') + data = self.query(self.SIGNIN, method=self._session.post, auth=(username, password), timeout=timeout) + return data, self.SIGNIN + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self._token = logfilter.add_secret(data.attrib.get('authenticationToken')) + self._webhooks = [] + self.authenticationToken = self._token + self.certificateVersion = data.attrib.get('certificateVersion') + self.cloudSyncDevice = data.attrib.get('cloudSyncDevice') + self.email = data.attrib.get('email') + self.guest = utils.cast(bool, data.attrib.get('guest')) + self.home = utils.cast(bool, data.attrib.get('home')) + self.homeSize = utils.cast(int, data.attrib.get('homeSize')) + self.id = data.attrib.get('id') + self.locale = data.attrib.get('locale') + self.mailing_list_status = data.attrib.get('mailing_list_status') + self.maxHomeSize = utils.cast(int, data.attrib.get('maxHomeSize')) + self.queueEmail = data.attrib.get('queueEmail') + self.queueUid = data.attrib.get('queueUid') + self.restricted = utils.cast(bool, data.attrib.get('restricted')) + self.scrobbleTypes = data.attrib.get('scrobbleTypes') + self.secure = utils.cast(bool, data.attrib.get('secure')) + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.username = data.attrib.get('username') + self.uuid = data.attrib.get('uuid') + # TODO: Fetch missing MyPlexAccount attributes + self.subscriptionActive = None # renamed on server + self.subscriptionStatus = None # renamed on server + self.subscriptionPlan = None # renmaed on server + self.subscriptionFeatures = None # renamed on server + self.roles = None + self.entitlements = None + + def device(self, name): + """ Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified. + + Parameters: + name (str): Name to match against. + """ + for device in self.devices(): + if device.name.lower() == name.lower(): + return device + raise NotFound('Unable to find device %s' % name) + + def devices(self): + """ Returns a list of all :class:`~plexapi.myplex.MyPlexDevice` objects connected to the server. """ + data = self.query(MyPlexDevice.key) + return [MyPlexDevice(self, elem) for elem in data] + + def _headers(self, **kwargs): + """ Returns dict containing base headers for all requests to the server. """ + headers = BASE_HEADERS.copy() + if self._token: + headers['X-Plex-Token'] = self._token + headers.update(kwargs) + return headers + + def query(self, url, method=None, headers=None, timeout=None, **kwargs): + method = method or self._session.get + timeout = timeout or TIMEOUT + log.debug('%s %s %s', method.__name__.upper(), url, kwargs.get('json', '')) + headers = self._headers(**headers or {}) + response = method(url, headers=headers, timeout=timeout, **kwargs) + if response.status_code not in (200, 201, 204): # pragma: no cover + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) + raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) + data = response.text.encode('utf8') + return ElementTree.fromstring(data) if data.strip() else None + + def resource(self, name): + """ Returns the :class:`~plexapi.myplex.MyPlexResource` that matches the name specified. + + Parameters: + name (str): Name to match against. + """ + for resource in self.resources(): + if resource.name.lower() == name.lower(): + return resource + raise NotFound('Unable to find resource %s' % name) + + def resources(self): + """ Returns a list of all :class:`~plexapi.myplex.MyPlexResource` objects connected to the server. """ + data = self.query(MyPlexResource.key) + return [MyPlexResource(self, elem) for elem in data] + + def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Share library content with the specified user. + + Parameters: + user (str): MyPlexUser, username, email of the user to be added. + server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. + sections ([Section]): Library sections, names or ids to be shared (default None shares all sections). + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: {'label':['foo']} + """ + username = user.username if isinstance(user, MyPlexUser) else user + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(machineId, sections) + params = { + 'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_email': username}, + 'sharing_settings': { + 'allowSync': ('1' if allowSync else '0'), + 'allowCameraUpload': ('1' if allowCameraUpload else '0'), + 'allowChannels': ('1' if allowChannels else '0'), + 'filterMovies': self._filterDictToStr(filterMovies or {}), + 'filterTelevision': self._filterDictToStr(filterTelevision or {}), + 'filterMusic': self._filterDictToStr(filterMusic or {}), + }, + } + headers = {'Content-Type': 'application/json'} + url = self.FRIENDINVITE.format(machineId=machineId) + return self.query(url, self._session.post, json=params, headers=headers) + + def removeFriend(self, user): + """ Remove the specified user from all sharing. + + Parameters: + user (str): MyPlexUser, username, email of the user to be added. + """ + user = self.user(user) + url = self.FRIENDUPDATE if user.friend else self.REMOVEINVITE + url = url.format(userId=user.id) + return self.query(url, self._session.delete) + + def updateFriend(self, user, server, sections=None, removeSections=False, allowSync=None, allowCameraUpload=None, + allowChannels=None, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Update the specified user's share settings. + + Parameters: + user (str): MyPlexUser, username, email of the user to be added. + server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. + sections: ([Section]): Library sections, names or ids to be shared (default None shares all sections). + removeSections (Bool): Set True to remove all shares. Supersedes sections. + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: {'label':['foo']} + """ + # Update friend servers + response_filters = '' + response_servers = '' + user = self.user(user.username if isinstance(user, MyPlexUser) else user) + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(machineId, sections) + headers = {'Content-Type': 'application/json'} + # Determine whether user has access to the shared server. + user_servers = [s for s in user.servers if s.machineIdentifier == machineId] + if user_servers and sectionIds: + serverId = user_servers[0].id + params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds}} + url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId) + else: + params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds, + "invited_id": user.id}} + url = self.FRIENDINVITE.format(machineId=machineId) + # Remove share sections, add shares to user without shares, or update shares + if sectionIds: + if removeSections is True: + response_servers = self.query(url, self._session.delete, json=params, headers=headers) + elif 'invited_id' in params.get('shared_server', ''): + response_servers = self.query(url, self._session.post, json=params, headers=headers) + else: + response_servers = self.query(url, self._session.put, json=params, headers=headers) + else: + log.warning('Section name, number of section object is required changing library sections') + # Update friend filters + url = self.FRIENDUPDATE.format(userId=user.id) + params = {} + if isinstance(allowSync, bool): + params['allowSync'] = '1' if allowSync else '0' + if isinstance(allowCameraUpload, bool): + params['allowCameraUpload'] = '1' if allowCameraUpload else '0' + if isinstance(allowChannels, bool): + params['allowChannels'] = '1' if allowChannels else '0' + if isinstance(filterMovies, dict): + params['filterMovies'] = self._filterDictToStr(filterMovies or {}) # '1' if allowChannels else '0' + if isinstance(filterTelevision, dict): + params['filterTelevision'] = self._filterDictToStr(filterTelevision or {}) + if isinstance(allowChannels, dict): + params['filterMusic'] = self._filterDictToStr(filterMusic or {}) + if params: + url += joinArgs(params) + response_filters = self.query(url, self._session.put) + return response_servers, response_filters + + def user(self, username): + """ Returns the :class:`~myplex.MyPlexUser` that matches the email or username specified. + + Parameters: + username (str): Username, email or id of the user to return. + """ + for user in self.users(): + # Home users don't have email, username etc. + if username.lower() == user.title.lower(): + return user + + elif (user.username and user.email and user.id and username.lower() in + (user.username.lower(), user.email.lower(), str(user.id))): + return user + + raise NotFound('Unable to find user %s' % username) + + def users(self): + """ Returns a list of all :class:`~plexapi.myplex.MyPlexUser` objects connected to your account. + This includes both friends and pending invites. You can reference the user.friend to + distinguish between the two. + """ + friends = [MyPlexUser(self, elem) for elem in self.query(MyPlexUser.key)] + requested = [MyPlexUser(self, elem, self.REQUESTED) for elem in self.query(self.REQUESTED)] + return friends + requested + + def _getSectionIds(self, server, sections): + """ Converts a list of section objects or names to sectionIds needed for library sharing. """ + if not sections: return [] + # Get a list of all section ids for looking up each section. + allSectionIds = {} + machineIdentifier = server.machineIdentifier if isinstance(server, PlexServer) else server + url = self.PLEXSERVERS.replace('{machineId}', machineIdentifier) + data = self.query(url, self._session.get) + for elem in data[0]: + allSectionIds[elem.attrib.get('id', '').lower()] = elem.attrib.get('id') + allSectionIds[elem.attrib.get('title', '').lower()] = elem.attrib.get('id') + allSectionIds[elem.attrib.get('key', '').lower()] = elem.attrib.get('id') + log.debug(allSectionIds) + # Convert passed in section items to section ids from above lookup + sectionIds = [] + for section in sections: + sectionKey = section.key if isinstance(section, LibrarySection) else section + sectionIds.append(allSectionIds[sectionKey.lower()]) + return sectionIds + + def _filterDictToStr(self, filterDict): + """ Converts friend filters to a string representation for transport. """ + values = [] + for key, vals in filterDict.items(): + if key not in ('contentRating', 'label'): + raise BadRequest('Unknown filter key: %s', key) + values.append('%s=%s' % (key, '%2C'.join(vals))) + return '|'.join(values) + + def addWebhook(self, url): + # copy _webhooks and append url + urls = self._webhooks[:] + [url] + return self.setWebhooks(urls) + + def deleteWebhook(self, url): + urls = copy.copy(self._webhooks) + if url not in urls: + raise BadRequest('Webhook does not exist: %s' % url) + urls.remove(url) + return self.setWebhooks(urls) + + def setWebhooks(self, urls): + log.info('Setting webhooks: %s' % urls) + data = self.query(self.WEBHOOKS, self._session.post, data={'urls[]': urls}) + self._webhooks = self.listAttrs(data, 'url', etag='webhook') + return self._webhooks + + def webhooks(self): + data = self.query(self.WEBHOOKS) + self._webhooks = self.listAttrs(data, 'url', etag='webhook') + return self._webhooks + + def optOut(self, playback=None, library=None): + """ Opt in or out of sharing stuff with plex. + See: https://www.plex.tv/about/privacy-legal/ + """ + params = {} + if playback is not None: + params['optOutPlayback'] = int(playback) + if library is not None: + params['optOutLibraryStats'] = int(library) + url = 'https://plex.tv/api/v2/user/privacy' + return self.query(url, method=self._session.put, params=params) + + +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` + which is your specific account. The raw xml for the data presented here + can be found at: https://plex.tv/api/users/ + + Attributes: + TAG (str): 'User' + key (str): 'https://plex.tv/api/users/' + allowCameraUpload (bool): True if this user can upload images. + allowChannels (bool): True if this user has access to channels. + allowSync (bool): True if this user can sync. + email (str): User's email address (user@gmail.com). + filterAll (str): Unknown. + filterMovies (str): Unknown. + filterMusic (str): Unknown. + filterPhotos (str): Unknown. + filterTelevision (str): Unknown. + home (bool): Unknown. + id (int): User's Plex account ID. + protected (False): Unknown (possibly SSL enabled?). + recommendationsPlaylistId (str): Unknown. + restricted (str): Unknown. + thumb (str): Link to the users avatar. + 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): + """ Load attribute values from Plex XML response. """ + self._data = data + self.friend = self._initpath == self.key + self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload')) + self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels')) + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.email = data.attrib.get('email') + self.filterAll = data.attrib.get('filterAll') + self.filterMovies = data.attrib.get('filterMovies') + self.filterMusic = data.attrib.get('filterMusic') + self.filterPhotos = data.attrib.get('filterPhotos') + self.filterTelevision = data.attrib.get('filterTelevision') + self.home = utils.cast(bool, data.attrib.get('home')) + self.id = utils.cast(int, data.attrib.get('id')) + self.protected = utils.cast(bool, data.attrib.get('protected')) + self.recommendationsPlaylistId = data.attrib.get('recommendationsPlaylistId') + self.restricted = data.attrib.get('restricted') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title', '') + self.username = data.attrib.get('username', '') + self.servers = self.findItems(data, MyPlexServerShare) + + def get_token(self, machineIdentifier): + try: + for item in self._server.query(self._server.FRIENDINVITE.format(machineId=machineIdentifier)): + if utils.cast(int, item.attrib.get('userID')) == self.id: + return item.attrib.get('accessToken') + except Exception: + log.exception('Failed to get access token for %s' % self.title) + + +class Section(PlexObject): + """ This refers to a shared section. The raw xml for the data presented here + can be found at: https://plex.tv/api/servers/{machineId}/shared_servers/{serverId} + + Attributes: + TAG (str): section + id (int): shared section id + sectionKey (str): what key we use for this section + title (str): Title of the section + sectionId (str): shared section id + type (str): movie, tvshow, artist + shared (bool): If this section is shared with the user + + """ + TAG = 'Section' + + def _loadData(self, data): + self._data = data + # self.id = utils.cast(int, data.attrib.get('id')) # Havnt decided if this should be changed. + self.sectionKey = data.attrib.get('key') + self.title = data.attrib.get('title') + self.sectionId = data.attrib.get('id') + self.type = data.attrib.get('type') + self.shared = utils.cast(bool, data.attrib.get('shared')) + + +class MyPlexServerShare(PlexObject): + """ Represents a single user's server reference. Used for library sharing. + + Attributes: + id (int): id for this share + serverId (str): what id plex uses for this. + machineIdentifier (str): The servers machineIdentifier + name (str): The servers name + lastSeenAt (datetime): Last connected to the server? + numLibraries (int): Total number of libraries + allLibraries (bool): True if all libraries is shared with this user. + owned (bool): 1 if the server is owned by the user + pending (bool): True if the invite is pending. + + """ + TAG = 'Server' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.id = utils.cast(int, data.attrib.get('id')) + self.serverId = utils.cast(int, data.attrib.get('serverId')) + self.machineIdentifier = data.attrib.get('machineIdentifier') + self.name = data.attrib.get('name') + self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) + self.numLibraries = utils.cast(int, data.attrib.get('numLibraries')) + self.allLibraries = utils.cast(bool, data.attrib.get('allLibraries')) + self.owned = utils.cast(bool, data.attrib.get('owned')) + self.pending = utils.cast(bool, data.attrib.get('pending')) + + def sections(self): + url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id) + data = self._server.query(url) + sections = [] + + for section in data.iter('Section'): + if ElementTree.iselement(section): + sections.append(Section(self, section, url)) + + return sections + + +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 + for the data presented here can be found at: + https://plex.tv/api/resources?includeHttps=1&includeRelay=1 + + Attributes: + TAG (str): 'Device' + key (str): 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1' + accessToken (str): This resources accesstoken. + clientIdentifier (str): Unique ID for this resource. + connections (list): List of :class:`~myplex.ResourceConnection` objects + for this resource. + createdAt (datetime): Timestamp this resource first connected to your server. + device (str): Best guess on the type of device this is (PS, iPhone, Linux, etc). + home (bool): Unknown + lastSeenAt (datetime): Timestamp this resource last connected. + name (str): Descriptive name of this resource. + owned (bool): True if this resource is one of your own (you logged into it). + platform (str): OS the resource is running (Linux, Windows, Chrome, etc.) + platformVersion (str): Version of the platform. + presence (bool): True if the resource is online + product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.) + productVersion (str): Version of the product. + provides (str): List of services this resource provides (client, server, + 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&includeRelay=1' + + def _loadData(self, data): + self._data = data + self.name = data.attrib.get('name') + self.accessToken = logfilter.add_secret(data.attrib.get('accessToken')) + self.product = data.attrib.get('product') + self.productVersion = data.attrib.get('productVersion') + self.platform = data.attrib.get('platform') + self.platformVersion = data.attrib.get('platformVersion') + self.device = data.attrib.get('device') + self.clientIdentifier = data.attrib.get('clientIdentifier') + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) + self.provides = data.attrib.get('provides') + self.owned = utils.cast(bool, data.attrib.get('owned')) + 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.findItems(data, ResourceConnection) + self.publicAddressMatches = utils.cast(bool, data.attrib.get('publicAddressMatches')) + # This seems to only be available if its not your device (say are shared server) + self.httpsRequired = utils.cast(bool, data.attrib.get('httpsRequired')) + self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0)) + self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username. + + def connect(self, ssl=None, timeout=None): + """ Returns a new :class:`~server.PlexServer` or :class:`~client.PlexClient` object. + Often times there is more than one address specified for a server or client. + This function will prioritize local connections before remote and HTTPS before HTTP. + After trying to connect to all available addresses for this resource and + assuming at least one connection was successful, the PlexServer object is built and returned. + + Parameters: + ssl (optional): Set True to only connect to HTTPS connections. Set False to + only connect to HTTP connections. Set None (default) to connect to any + HTTP or HTTPS connection. + + Raises: + :class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource. + """ + # Sort connections from (https, local) to (http, remote) + # Only check non-local connections unless we own the resource + connections = sorted(self.connections, key=lambda c: c.local, reverse=True) + owned_or_unowned_non_local = lambda x: self.owned or (not self.owned and not x.local) + https = [c.uri for c in connections if owned_or_unowned_non_local(c)] + http = [c.httpuri for c in connections if owned_or_unowned_non_local(c)] + cls = PlexServer if 'server' in self.provides else PlexClient + # Force ssl, no ssl, or any (default) + if ssl is True: connections = https + elif ssl is False: connections = http + else: connections = https + http + # Try connecting to all known resource connections in parellel, but + # only return the first server (in order) that provides a response. + listargs = [[cls, url, self.accessToken, timeout] for url in connections] + log.info('Testing %s resource connections..', len(listargs)) + results = utils.threaded(_connect, listargs) + return _chooseConnection('Resource', self.name, results) + + +class ResourceConnection(PlexObject): + """ Represents a Resource Connection object found within the + :class:`~myplex.MyPlexResource` objects. + + Attributes: + TAG (str): 'Connection' + address (str): Local IP address + httpuri (str): Full local address + local (bool): True if local + port (int): 32400 + protocol (str): HTTP or HTTPS + uri (str): External address + """ + TAG = 'Connection' + + def _loadData(self, data): + self._data = data + self.protocol = data.attrib.get('protocol') + self.address = data.attrib.get('address') + self.port = utils.cast(int, data.attrib.get('port')) + self.uri = data.attrib.get('uri') + self.local = utils.cast(bool, data.attrib.get('local')) + self.httpuri = 'http://%s:%s' % (self.address, self.port) + self.relay = utils.cast(bool, data.attrib.get('relay')) + + +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, + this API, etc. The raw xml for the data presented here can be found at: + https://plex.tv/devices.xml + + Attributes: + TAG (str): 'Device' + key (str): 'https://plex.tv/devices.xml' + clientIdentifier (str): Unique ID for this resource. + connections (list): List of connection URIs for the device. + device (str): Best guess on the type of device this is (Linux, iPad, AFTB, etc). + id (str): MyPlex ID of the device. + model (str): Model of the device (bueller, Linux, x86_64, etc.) + name (str): Hostname of the device. + platform (str): OS the resource is running (Linux, Windows, Chrome, etc.) + platformVersion (str): Version of the platform. + product (str): Plex product (Plex Media Server, Plex for iOS, Plex Web, etc.) + productVersion (string): Version of the product. + provides (str): List of services this resource provides (client, controller, + sync-target, player, pubsub-player). + publicAddress (str): Public IP address. + screenDensity (str): Unknown + screenResolution (str): Screen resolution (750x1334, 1242x2208, etc.) + token (str): Plex authentication token for the device. + 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): + self._data = data + self.name = data.attrib.get('name') + self.publicAddress = data.attrib.get('publicAddress') + self.product = data.attrib.get('product') + self.productVersion = data.attrib.get('productVersion') + self.platform = data.attrib.get('platform') + self.platformVersion = data.attrib.get('platformVersion') + self.device = data.attrib.get('device') + self.model = data.attrib.get('model') + self.vendor = data.attrib.get('vendor') + self.provides = data.attrib.get('provides') + self.clientIdentifier = data.attrib.get('clientIdentifier') + self.version = data.attrib.get('version') + self.id = data.attrib.get('id') + self.token = logfilter.add_secret(data.attrib.get('token')) + self.screenResolution = data.attrib.get('screenResolution') + self.screenDensity = data.attrib.get('screenDensity') + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) + self.connections = [connection.attrib.get('uri') for connection in data.iter('Connection')] + + def connect(self, timeout=None): + """ Returns a new :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer` + Sometimes there is more than one address specified for a server or client. + After trying to connect to all available addresses for this client and assuming + at least one connection was successful, the PlexClient object is built and returned. + + Raises: + :class:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device. + """ + cls = PlexServer if 'server' in self.provides else PlexClient + listargs = [[cls, url, self.token, timeout] for url in self.connections] + log.info('Testing %s device connections..', len(listargs)) + results = utils.threaded(_connect, listargs) + return _chooseConnection('Device', self.name, results) + + def delete(self): + """ Remove this device from your account. """ + key = 'https://plex.tv/devices/%s.xml' % self.id + self._server.query(key, self._server._session.delete) + + +def _connect(cls, url, token, timeout, results, i): + """ Connects to the specified cls with url and token. Stores the connection + information to results[i] in a threadsafe way. + """ + starttime = time.time() + try: + device = cls(baseurl=url, token=token, timeout=timeout) + runtime = int(time.time() - starttime) + results[i] = (url, token, device, runtime) + except Exception as err: + runtime = int(time.time() - starttime) + log.error('%s: %s', url, err) + results[i] = (url, token, None, runtime) + + +def _chooseConnection(ctype, name, results): + """ Chooses the first (best) connection from the given _connect results. """ + # At this point we have a list of result tuples containing (url, token, PlexServer, runtime) + # or (url, token, None, runtime) in the case a connection could not be established. + for url, token, result, runtime in results: + okerr = 'OK' if result else 'ERR' + log.info('%s connection %s (%ss): %s?X-Plex-Token=%s', ctype, okerr, runtime, url, token) + results = [r[2] for r in results if r and r[2] is not None] + if results: + log.info('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token) + return results[0] + raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name)) diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py new file mode 100644 index 00000000..50db79f5 --- /dev/null +++ b/lib/plexapi/photo.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +from plexapi import media, utils +from plexapi.base import PlexPartialObject +from plexapi.exceptions import NotFound + + +@utils.registerPlexObject +class Photoalbum(PlexPartialObject): + """ Represents a photoalbum (collection of photos). + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'photo' + addedAt (datetime): Datetime this item was added to the library. + art (str): Photo art (/library/metadata//art/) + composite (str): Unknown + guid (str): Unknown (unique ID) + index (sting): Index number of this album. + key (str): API URL (/library/metadata/). + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + listType (str): Hardcoded as 'photo' (useful for search filters). + ratingKey (int): Unique key identifying this item. + summary (str): Summary of the photoalbum. + thumb (str): URL to thumbnail image. + title (str): Photoalbum title. (Trip to Disney World) + type (str): Unknown + updatedAt (datatime): Datetime this item was updated. + """ + TAG = 'Directory' + TYPE = 'photo' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self.listType = 'photo' + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.art = data.attrib.get('art') + self.composite = data.attrib.get('composite') + self.guid = data.attrib.get('guid') + self.index = utils.cast(int, data.attrib.get('index')) + self.key = data.attrib.get('key') + self.librarySectionID = data.attrib.get('librarySectionID') + self.ratingKey = data.attrib.get('ratingKey') + self.summary = data.attrib.get('summary') + 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')) + + def albums(self, **kwargs): + """ Returns a list of :class:`~plexapi.photo.Photoalbum` objects in this album. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, etag='Directory', **kwargs) + + def album(self, title): + """ Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title. """ + for album in self.albums(): + if album.title.lower() == title.lower(): + return album + raise NotFound('Unable to find album: %s' % title) + + def photos(self, **kwargs): + """ Returns a list of :class:`~plexapi.photo.Photo` objects in this album. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, etag='Photo', **kwargs) + + def photo(self, title): + """ Returns the :class:`~plexapi.photo.Photo` that matches the specified title. """ + for photo in self.photos(): + if photo.title.lower() == title.lower(): + return photo + raise NotFound('Unable to find photo: %s' % title) + + +@utils.registerPlexObject +class Photo(PlexPartialObject): + """ Represents a single photo. + + Attributes: + TAG (str): 'Photo' + TYPE (str): 'photo' + addedAt (datetime): Datetime this item was added to the library. + index (sting): Index number of this photo. + key (str): API URL (/library/metadata/). + listType (str): Hardcoded as 'photo' (useful for search filters). + media (TYPE): Unknown + originallyAvailableAt (datetime): Datetime this photo was added to Plex. + parentKey (str): Photoalbum API URL. + parentRatingKey (int): Unique key identifying the photoalbum. + ratingKey (int): Unique key identifying this item. + summary (str): Summary of the photo. + thumb (str): URL to thumbnail image. + title (str): Photo title. + type (str): Unknown + updatedAt (datatime): Datetime this item was updated. + year (int): Year this photo was taken. + """ + TAG = 'Photo' + TYPE = 'photo' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self.listType = 'photo' + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.index = utils.cast(int, data.attrib.get('index')) + self.key = data.attrib.get('key') + self.originallyAvailableAt = utils.toDatetime( + data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.parentKey = data.attrib.get('parentKey') + self.parentRatingKey = data.attrib.get('parentRatingKey') + self.ratingKey = data.attrib.get('ratingKey') + self.summary = data.attrib.get('summary') + 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.year = utils.cast(int, data.attrib.get('year')) + self.media = self.findItems(data, media.Media) + + def photoalbum(self): + """ Return this photo's :class:`~plexapi.photo.Photoalbum`. """ + return self.fetchItem(self.parentKey) + + def section(self): + """ Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """ + return self._server.library.sectionByID(self.photoalbum().librarySectionID) diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py new file mode 100644 index 00000000..06e18ffa --- /dev/null +++ b/lib/plexapi/playlist.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +from plexapi import utils +from plexapi.base import PlexPartialObject, Playable +from plexapi.exceptions import BadRequest +from plexapi.playqueue import PlayQueue +from plexapi.utils import cast, toDatetime + + +@utils.registerPlexObject +class Playlist(PlexPartialObject, Playable): + """ Represents a single Playlist object. + # TODO: Document attributes + """ + TAG = 'Playlist' + TYPE = 'playlist' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Playable._loadData(self, data) + self.addedAt = toDatetime(data.attrib.get('addedAt')) + self.composite = data.attrib.get('composite') # url to thumbnail + self.duration = cast(int, data.attrib.get('duration')) + self.durationInSeconds = cast(int, data.attrib.get('durationInSeconds')) + self.guid = data.attrib.get('guid') + self.key = data.attrib.get('key') + self.key = self.key.replace('/items', '') if self.key else self.key # FIX_BUG_50 + self.leafCount = cast(int, data.attrib.get('leafCount')) + self.playlistType = data.attrib.get('playlistType') + self.ratingKey = cast(int, data.attrib.get('ratingKey')) + self.smart = cast(bool, data.attrib.get('smart')) + self.summary = data.attrib.get('summary') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.updatedAt = toDatetime(data.attrib.get('updatedAt')) + self._items = None # cache for self.items + + def __len__(self): # pragma: no cover + return len(self.items()) + + def __contains__(self, other): # pragma: no cover + return any(i.key == other.key for i in self.items()) + + def __getitem__(self, key): # pragma: no cover + return self.items()[key] + + def items(self): + """ Returns a list of all items in the playlist. """ + if self._items is None: + key = '%s/items' % self.key + items = self.fetchItems(key) + self._items = items + return self._items + + def addItems(self, items): + """ Add items to a playlist. """ + if not isinstance(items, (list, tuple)): + items = [items] + ratingKeys = [] + for item in items: + if item.listType != self.playlistType: # pragma: no cover + raise BadRequest('Can not mix media types when building a playlist: %s and %s' % + (self.playlistType, item.listType)) + ratingKeys.append(str(item.ratingKey)) + uuid = items[0].section().uuid + ratingKeys = ','.join(ratingKeys) + key = '%s/items%s' % (self.key, utils.joinArgs({ + 'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys) + })) + result = self._server.query(key, method=self._server._session.put) + self.reload() + return result + + def removeItem(self, item): + """ Remove a file from a playlist. """ + key = '%s/items/%s' % (self.key, item.playlistItemID) + result = self._server.query(key, method=self._server._session.delete) + self.reload() + return result + + def moveItem(self, item, after=None): + """ Move a to a new position in playlist. """ + key = '%s/items/%s/move' % (self.key, item.playlistItemID) + if after: + key += '?after=%s' % after.playlistItemID + result = self._server.query(key, method=self._server._session.put) + self.reload() + return result + + def edit(self, title=None, summary=None): + """ Edit playlist. """ + key = '/library/metadata/%s%s' % (self.ratingKey, utils.joinArgs({'title': title, 'summary': summary})) + result = self._server.query(key, method=self._server._session.put) + self.reload() + return result + + def delete(self): + """ Delete playlist. """ + return self._server.query(self.key, method=self._server._session.delete) + + def playQueue(self, *args, **kwargs): + """ Create a playqueue from this playlist. """ + return PlayQueue.create(self._server, self, *args, **kwargs) + + @classmethod + def create(cls, server, title, items): + """ Create a playlist. """ + if not isinstance(items, (list, tuple)): + items = [items] + ratingKeys = [] + for item in items: + if item.listType != items[0].listType: # pragma: no cover + raise BadRequest('Can not mix media types when building a playlist') + ratingKeys.append(str(item.ratingKey)) + ratingKeys = ','.join(ratingKeys) + uuid = items[0].section().uuid + key = '/playlists%s' % utils.joinArgs({ + 'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys), + 'type': items[0].listType, + 'title': title, + 'smart': 0 + }) + data = server.query(key, method=server._session.post)[0] + return cls(server, data, initpath=key) + + def copyToUser(self, user): + """ Copy playlist to another user account. """ + from plexapi.server import PlexServer + myplex = self._server.myPlexAccount() + user = myplex.user(user) + # Get the token for your machine. + token = user.get_token(self._server.machineIdentifier) + # Login to your server using your friends credentials. + user_server = PlexServer(self._server._baseurl, token) + return self.create(user_server, self.title, self.items()) diff --git a/lib/plexapi/playqueue.py b/lib/plexapi/playqueue.py new file mode 100644 index 00000000..08aa774c --- /dev/null +++ b/lib/plexapi/playqueue.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +from plexapi import utils +from plexapi.base import PlexObject + + +class PlayQueue(PlexObject): + """ Control a PlayQueue. + + Attributes: + key (str): This is only added to support playMedia + identifier (str): com.plexapp.plugins.library + initpath (str): Relative url where data was grabbed from. + items (list): List of :class:`~plexapi.media.Media` or class:`~plexapi.playlist.Playlist` + mediaTagPrefix (str): Fx /system/bundle/media/flags/ + mediaTagVersion (str): Fx 1485957738 + playQueueID (str): a id for the playqueue + playQueueSelectedItemID (str): playQueueSelectedItemID + playQueueSelectedItemOffset (str): playQueueSelectedItemOffset + playQueueSelectedMetadataItemID (): 7 + playQueueShuffled (bool): True if shuffled + playQueueSourceURI (str): Fx library://150425c9-0d99-4242-821e-e5ab81cd2221/item//library/metadata/7 + playQueueTotalCount (str): How many items in the play queue. + playQueueVersion (str): What version the playqueue is. + server (:class:`~plexapi.server.PlexServer`): Server you are connected to. + size (str): Seems to be a alias for playQueueTotalCount. + """ + + def _loadData(self, data): + self._data = data + self.identifier = data.attrib.get('identifier') + self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') + self.mediaTagVersion = data.attrib.get('mediaTagVersion') + self.playQueueID = data.attrib.get('playQueueID') + self.playQueueSelectedItemID = data.attrib.get('playQueueSelectedItemID') + self.playQueueSelectedItemOffset = data.attrib.get('playQueueSelectedItemOffset') + self.playQueueSelectedMetadataItemID = data.attrib.get('playQueueSelectedMetadataItemID') + self.playQueueShuffled = utils.cast(bool, data.attrib.get('playQueueShuffled', 0)) + self.playQueueSourceURI = data.attrib.get('playQueueSourceURI') + self.playQueueTotalCount = data.attrib.get('playQueueTotalCount') + self.playQueueVersion = data.attrib.get('playQueueVersion') + self.size = utils.cast(int, data.attrib.get('size', 0)) + self.items = self.findItems(data) + + @classmethod + def create(cls, server, item, shuffle=0, repeat=0, includeChapters=1, includeRelated=1): + """ Create and returns a new :class:`~plexapi.playqueue.PlayQueue`. + + Paramaters: + server (:class:`~plexapi.server.PlexServer`): Server you are connected to. + item (:class:`~plexapi.media.Media` or class:`~plexapi.playlist.Playlist`): A media or Playlist. + shuffle (int, optional): Start the playqueue shuffled. + repeat (int, optional): Start the playqueue shuffled. + includeChapters (int, optional): include Chapters. + includeRelated (int, optional): include Related. + """ + args = {} + args['includeChapters'] = includeChapters + args['includeRelated'] = includeRelated + args['repeat'] = repeat + args['shuffle'] = shuffle + if item.type == 'playlist': + args['playlistID'] = item.ratingKey + args['type'] = item.playlistType + else: + uuid = item.section().uuid + args['key'] = item.key + args['type'] = item.listType + args['uri'] = 'library://%s/item/%s' % (uuid, item.key) + path = '/playQueues%s' % utils.joinArgs(args) + data = server.query(path, method=server._session.post) + c = cls(server, data, initpath=path) + # we manually add a key so we can pass this to playMedia + # since the data, does not contain a key. + c.key = item.key + return c diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py new file mode 100644 index 00000000..849b4c69 --- /dev/null +++ b/lib/plexapi/server.py @@ -0,0 +1,471 @@ +# -*- coding: utf-8 -*- +import requests +from requests.status_codes import _codes as codes +from plexapi import BASE_HEADERS, CONFIG, TIMEOUT +from plexapi import log, logfilter, utils +from plexapi.alert import AlertListener +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, Hub +from plexapi.settings import Settings +from plexapi.playlist import Playlist +from plexapi.playqueue import PlayQueue +from plexapi.utils import cast + +# Need these imports to populate utils.PLEXOBJECTS +from plexapi import (audio as _audio, video as _video, # noqa: F401 + photo as _photo, media as _media, playlist as _playlist) # noqa: F401 + + +class PlexServer(PlexObject): + """ This is the main entry point to interacting with a Plex server. It allows you to + list connected clients, browse your library sections and perform actions such as + emptying trash. If you do not know the auth token required to access your Plex + server, or simply want to access your server with your username and password, you + can also create an PlexServer instance from :class:`~plexapi.myplex.MyPlexAccount`. + + Parameters: + baseurl (str): Base url for to access the Plex Media Server (default: 'http://localhost:32400'). + token (str): Required Plex authentication token to access the server. + session (requests.Session, optional): Use your own session object if you want to + cache the http responses from PMS + timeout (int): timeout in seconds on initial connect to server (default config.TIMEOUT). + + Attributes: + allowCameraUpload (bool): True if server allows camera upload. + allowChannelAccess (bool): True if server allows channel access (iTunes?). + allowMediaDeletion (bool): True is server allows media to be deleted. + allowSharing (bool): True is server allows sharing. + allowSync (bool): True is server allows sync. + backgroundProcessing (bool): Unknown + certificate (bool): True if server has an HTTPS certificate. + companionProxy (bool): Unknown + diagnostics (bool): Unknown + eventStream (bool): Unknown + friendlyName (str): Human friendly name for this server. + hubSearch (bool): True if `Hub Search `_ is enabled. I believe this + is enabled for everyone + machineIdentifier (str): Unique ID for this server (looks like an md5). + multiuser (bool): True if `multiusers `_ are enabled. + myPlex (bool): Unknown (True if logged into myPlex?). + myPlexMappingState (str): Unknown (ex: mapped). + myPlexSigninState (str): Unknown (ex: ok). + myPlexSubscription (bool): True if you have a myPlex subscription. + myPlexUsername (str): Email address if signed into myPlex (user@example.com) + ownerFeatures (list): List of features allowed by the server owner. This may be based + on your PlexPass subscription. Features include: camera_upload, cloudsync, + content_filter, dvr, hardware_transcoding, home, lyrics, music_videos, pass, + photo_autotags, premium_music_metadata, session_bandwidth_restrictions, sync, + trailers, webhooks (and maybe more). + photoAutoTag (bool): True if photo `auto-tagging `_ is enabled. + platform (str): Platform the server is hosted on (ex: Linux) + platformVersion (str): Platform version (ex: '6.1 (Build 7601)', '4.4.0-59-generic'). + pluginHost (bool): Unknown + readOnlyLibraries (bool): Unknown + requestParametersInCookie (bool): Unknown + streamingBrainVersion (bool): Current `Streaming Brain `_ version. + sync (bool): True if `syncing to a device `_ is enabled. + transcoderActiveVideoSessions (int): Number of active video transcoding sessions. + transcoderAudio (bool): True if audio transcoding audio is available. + transcoderLyrics (bool): True if audio transcoding lyrics is available. + transcoderPhoto (bool): True if audio transcoding photos is available. + transcoderSubtitles (bool): True if audio transcoding subtitles is available. + transcoderVideo (bool): True if audio transcoding video is available. + transcoderVideoBitrates (bool): List of video bitrates. + transcoderVideoQualities (bool): List of video qualities. + transcoderVideoResolutions (bool): List of video resolutions. + updatedAt (int): Datetime the server was updated. + updater (bool): Unknown + version (str): Current Plex version (ex: 1.3.2.3112-1751929) + voiceSearch (bool): True if voice search is enabled. (is this Google Voice search?) + _baseurl (str): HTTP address of the client. + _token (str): Token used to access this client. + _session (obj): Requests session object used to access this client. + """ + key = '/' + + def __init__(self, baseurl=None, token=None, session=None, timeout=None): + self._baseurl = baseurl or CONFIG.get('auth.server_baseurl', 'http://localhost:32400') + self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token')) + self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' + self._session = session or requests.Session() + self._library = None # cached library + self._settings = None # cached settings + self._myPlexAccount = None # cached myPlexAccount + data = self.query(self.key, timeout=timeout) + super(PlexServer, self).__init__(self, data, self.key) + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.allowCameraUpload = cast(bool, data.attrib.get('allowCameraUpload')) + self.allowChannelAccess = cast(bool, data.attrib.get('allowChannelAccess')) + self.allowMediaDeletion = cast(bool, data.attrib.get('allowMediaDeletion')) + self.allowSharing = cast(bool, data.attrib.get('allowSharing')) + self.allowSync = cast(bool, data.attrib.get('allowSync')) + self.backgroundProcessing = cast(bool, data.attrib.get('backgroundProcessing')) + self.certificate = cast(bool, data.attrib.get('certificate')) + self.companionProxy = cast(bool, data.attrib.get('companionProxy')) + self.diagnostics = utils.toList(data.attrib.get('diagnostics')) + self.eventStream = cast(bool, data.attrib.get('eventStream')) + self.friendlyName = data.attrib.get('friendlyName') + self.hubSearch = cast(bool, data.attrib.get('hubSearch')) + self.machineIdentifier = data.attrib.get('machineIdentifier') + self.multiuser = cast(bool, data.attrib.get('multiuser')) + self.myPlex = cast(bool, data.attrib.get('myPlex')) + self.myPlexMappingState = data.attrib.get('myPlexMappingState') + self.myPlexSigninState = data.attrib.get('myPlexSigninState') + self.myPlexSubscription = cast(bool, data.attrib.get('myPlexSubscription')) + self.myPlexUsername = data.attrib.get('myPlexUsername') + self.ownerFeatures = utils.toList(data.attrib.get('ownerFeatures')) + self.photoAutoTag = cast(bool, data.attrib.get('photoAutoTag')) + self.platform = data.attrib.get('platform') + self.platformVersion = data.attrib.get('platformVersion') + self.pluginHost = cast(bool, data.attrib.get('pluginHost')) + self.readOnlyLibraries = cast(int, data.attrib.get('readOnlyLibraries')) + self.requestParametersInCookie = cast(bool, data.attrib.get('requestParametersInCookie')) + self.streamingBrainVersion = data.attrib.get('streamingBrainVersion') + self.sync = cast(bool, data.attrib.get('sync')) + self.transcoderActiveVideoSessions = int(data.attrib.get('transcoderActiveVideoSessions', 0)) + self.transcoderAudio = cast(bool, data.attrib.get('transcoderAudio')) + self.transcoderLyrics = cast(bool, data.attrib.get('transcoderLyrics')) + self.transcoderPhoto = cast(bool, data.attrib.get('transcoderPhoto')) + self.transcoderSubtitles = cast(bool, data.attrib.get('transcoderSubtitles')) + self.transcoderVideo = cast(bool, data.attrib.get('transcoderVideo')) + self.transcoderVideoBitrates = utils.toList(data.attrib.get('transcoderVideoBitrates')) + self.transcoderVideoQualities = utils.toList(data.attrib.get('transcoderVideoQualities')) + self.transcoderVideoResolutions = utils.toList(data.attrib.get('transcoderVideoResolutions')) + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.updater = cast(bool, data.attrib.get('updater')) + self.version = data.attrib.get('version') + self.voiceSearch = cast(bool, data.attrib.get('voiceSearch')) + + def _headers(self, **kwargs): + """ Returns dict containing base headers for all requests to the server. """ + headers = BASE_HEADERS.copy() + if self._token: + headers['X-Plex-Token'] = self._token + headers.update(kwargs) + return headers + + @property + def library(self): + """ Library to browse or search your media. """ + if not self._library: + try: + data = self.query(Library.key) + self._library = Library(self, data) + except BadRequest: + data = self.query('/library/sections/') + # Only the owner has access to /library + # so just return the library without the data. + return Library(self, data) + return self._library + + @property + def settings(self): + """ Returns a list of all server settings. """ + if not self._settings: + data = self.query(Settings.key) + self._settings = Settings(self, data) + return self._settings + + def account(self): + """ Returns the :class:`~plexapi.server.Account` object this server belongs to. """ + data = self.query(Account.key) + return Account(self, data) + + def myPlexAccount(self): + """ Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same + token to access this server. If you are not the owner of this PlexServer + you're likley to recieve an authentication error calling this. + """ + if self._myPlexAccount is None: + from plexapi.myplex import MyPlexAccount + self._myPlexAccount = MyPlexAccount(token=self._token) + return self._myPlexAccount + + def _myPlexClientPorts(self): + """ Sometimes the PlexServer does not properly advertise port numbers required + to connect. This attemps to look up device port number from plex.tv. + See issue #126: Make PlexServer.clients() more user friendly. + https://github.com/pkkid/python-plexapi/issues/126 + """ + try: + ports = {} + account = self.myPlexAccount() + for device in account.devices(): + if device.connections and ':' in device.connections[0][6:]: + ports[device.clientIdentifier] = device.connections[0].split(':')[-1] + return ports + except Exception as err: + log.warning('Unable to fetch client ports from myPlex: %s', err) + return ports + + def clients(self): + """ Returns list of all :class:`~plexapi.client.PlexClient` objects connected to server. """ + items = [] + ports = None + for elem in self.query('/clients'): + port = elem.attrib.get('port') + if not port: + log.warning('%s did not advertise a port, checking plex.tv.', elem.attrib.get('name')) + ports = self._myPlexClientPorts() if ports is None else ports + port = ports.get(elem.attrib.get('machineIdentifier')) + baseurl = 'http://%s:%s' % (elem.attrib['host'], port) + items.append(PlexClient(baseurl=baseurl, server=self, + token=self._token, data=elem, connect=False)) + + return items + + def client(self, name): + """ Returns the :class:`~plexapi.client.PlexClient` that matches the specified name. + + Parameters: + name (str): Name of the client to return. + + Raises: + :class:`~plexapi.exceptions.NotFound`: Unknown client name + """ + for client in self.clients(): + if client and client.title == name: + return client + + raise NotFound('Unknown client name: %s' % name) + + def createPlaylist(self, title, items): + """ Creates and returns a new :class:`~plexapi.playlist.Playlist`. + + Parameters: + title (str): Title of the playlist to be created. + items (list): List of media items to include in the playlist. + """ + return Playlist.create(self, title, items) + + def createPlayQueue(self, item, **kwargs): + """ Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`. + + Parameters: + item (Media or Playlist): Media or playlist to add to PlayQueue. + kwargs (dict): See `~plexapi.playerque.PlayQueue.create`. + """ + return PlayQueue.create(self, item, **kwargs) + + def downloadDatabases(self, savepath=None, unpack=False): + """ Download databases. + + Parameters: + savepath (str): Defaults to current working dir. + unpack (bool): Unpack the zip file. + """ + url = self.url('/diagnostics/databases') + filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack) + return filepath + + def downloadLogs(self, savepath=None, unpack=False): + """ Download server logs. + + Parameters: + savepath (str): Defaults to current working dir. + unpack (bool): Unpack the zip file. + """ + url = self.url('/diagnostics/logs') + filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack) + return filepath + + def check_for_update(self, force=True, download=False): + """ Returns a :class:`~plexapi.base.Release` object containing release info. + + Parameters: + force (bool): Force server to check for new releases + download (bool): Download if a update is available. + """ + part = '/updater/check?download=%s' % (1 if download else 0) + if force: + self.query(part, method=self._session.put) + return self.fetchItem('/updater/status') + + def isLatest(self): + """ Check if the installed version of PMS is the latest. """ + release = self.check_for_update(force=True) + return bool(release.version == self.version) + + def installUpdate(self): + """ Install the newest version of Plex Media Server. """ + # We can add this but dunno how useful this is since it sometimes + # requires user action using a gui. + part = '/updater/apply' + release = self.check_for_update(force=True, download=True) + if release and release.version != self.version: + # figure out what method this is.. + return self.query(part, method=self._session.put) + + def history(self): + """ Returns a list of media items from watched history. """ + return self.fetchItems('/status/sessions/history/all') + + def playlists(self): + """ Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """ + # TODO: Add sort and type options? + # /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0 + return self.fetchItems('/playlists') + + def playlist(self, title): + """ Returns the :class:`~plexapi.client.Playlist` that matches the specified title. + + Parameters: + title (str): Title of the playlist to return. + + Raises: + :class:`~plexapi.exceptions.NotFound`: Invalid playlist title + """ + return self.fetchItem('/playlists', title=title) + + def query(self, key, method=None, headers=None, timeout=None, **kwargs): + """ Main method used to handle HTTPS requests to the Plex server. This method helps + by encoding the response to utf-8 and parsing the returned XML into and + ElementTree object. Returns None if no data exists in the response. + """ + url = self.url(key) + method = method or self._session.get + timeout = timeout or TIMEOUT + log.debug('%s %s', method.__name__.upper(), url) + headers = self._headers(**headers or {}) + response = method(url, headers=headers, timeout=timeout, **kwargs) + if response.status_code not in (200, 201): + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) + raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)) + data = response.text.encode('utf8') + return ElementTree.fromstring(data) if data.strip() else None + + def search(self, query, mediatype=None, limit=None): + """ Returns a list of media items or filter categories from the resulting + `Hub Search `_ + against all items in your Plex library. This searches genres, actors, directors, + playlists, as well as all the obvious media titles. It performs spell-checking + against your search terms (because KUROSAWA is hard to spell). It also provides + contextual search results. So for example, if you search for 'Pernice', it’ll + return 'Pernice Brothers' as the artist result, but we’ll also go ahead and + return your most-listened to albums and tracks from the artist. If you type + 'Arnold' you’ll get a result for the actor, but also the most recently added + movies he’s in. + + Parameters: + query (str): Query to use when searching your library. + mediatype (str): Optionally limit your search to the specified media type. + limit (int): Optionally limit to the specified number of results per Hub. + """ + results = [] + params = {'query': query} + if mediatype: + params['section'] = utils.SEARCHTYPES[mediatype] + if limit: + params['limit'] = limit + key = '/hubs/search?%s' % urlencode(params) + for hub in self.fetchItems(key, Hub): + results += hub.items + return results + + def sessions(self): + """ Returns a list of all active session (currently playing) media objects. """ + return self.fetchItems('/status/sessions') + + def startAlertListener(self, callback=None): + """ Creates a websocket connection to the Plex Server to optionally recieve + notifications. These often include messages from Plex about media scans + as well as updates to currently running Transcode Sessions. + + NOTE: You need websocket-client installed in order to use this feature. + >> pip install websocket-client + + Parameters: + callback (func): Callback function to call on recieved messages. + + raises: + :class:`~plexapi.exception.Unsupported`: Websocket-client not installed. + """ + notifier = AlertListener(self, callback) + notifier.start() + return notifier + + def transcodeImage(self, media, height, width, opacity=100, saturation=100): + """ Returns the URL for a transcoded image from the specified media object. + Returns None if no media specified (needed if user tries to pass thumb + or art directly). + + Parameters: + height (int): Height to transcode the image to. + width (int): Width to transcode the image to. + opacity (int): Opacity of the resulting image (possibly deprecated). + saturation (int): Saturating of the resulting image. + """ + 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, includeToken=True) + + def url(self, key, includeToken=None): + """ Build a URL string with proper token argument. Token will be appended to the URL + if either includeToken is True or CONFIG.log.show_secrets is 'true'. + """ + if self._token and (includeToken or self._showSecrets): + 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 + match the :class:`~plexapi.myplex.MyPlexAccount` object very well. I believe this exists + because access to myplex is not required to get basic plex information. I can't imagine + object is terribly useful except unless you were needed this information while offline. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): PlexServer this account is connected to (optional) + data (ElementTree): Response from PlexServer used to build this object (optional). + + Attributes: + authToken (str): Plex authentication token to access the server. + mappingError (str): Unknown + mappingErrorMessage (str): Unknown + mappingState (str): Unknown + privateAddress (str): Local IP address of the Plex server. + privatePort (str): Local port of the Plex server. + publicAddress (str): Public IP address of the Plex server. + publicPort (str): Public port of the Plex server. + signInState (str): Signin state for this account (ex: ok). + subscriptionActive (str): True if the account subscription is active. + subscriptionFeatures (str): List of features allowed by the server for this account. + This may be based on your PlexPass subscription. Features include: camera_upload, + cloudsync, content_filter, dvr, hardware_transcoding, home, lyrics, music_videos, + pass, photo_autotags, premium_music_metadata, session_bandwidth_restrictions, + sync, trailers, webhooks' (and maybe more). + subscriptionState (str): 'Active' if this subscription is active. + username (str): Plex account username (user@example.com). + """ + key = '/myplex/account' + + def _loadData(self, data): + self._data = data + self.authToken = data.attrib.get('authToken') + self.username = data.attrib.get('username') + self.mappingState = data.attrib.get('mappingState') + self.mappingError = data.attrib.get('mappingError') + self.mappingErrorMessage = data.attrib.get('mappingErrorMessage') + self.signInState = data.attrib.get('signInState') + self.publicAddress = data.attrib.get('publicAddress') + self.publicPort = data.attrib.get('publicPort') + self.privateAddress = data.attrib.get('privateAddress') + self.privatePort = data.attrib.get('privatePort') + self.subscriptionFeatures = utils.toList(data.attrib.get('subscriptionFeatures')) + self.subscriptionActive = cast(bool, data.attrib.get('subscriptionActive')) + self.subscriptionState = data.attrib.get('subscriptionState') diff --git a/lib/plexapi/settings.py b/lib/plexapi/settings.py new file mode 100644 index 00000000..9f85ebdc --- /dev/null +++ b/lib/plexapi/settings.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- +from collections import defaultdict + +from plexapi import log, utils +from plexapi.base import PlexObject +from plexapi.compat import quote, string_type +from plexapi.exceptions import BadRequest, NotFound + + +class Settings(PlexObject): + """ Container class for all settings. Allows getting and setting PlexServer settings. + + Attributes: + key (str): '/:/prefs' + """ + key = '/:/prefs' + + def __init__(self, server, data, initpath=None): + self._settings = {} + super(Settings, self).__init__(server, data, initpath) + + def __getattr__(self, attr): + if attr.startswith('_'): + return self.__dict__[attr] + return self.get(attr).value + + def __setattr__(self, attr, value): + if not attr.startswith('_'): + return self.get(attr).set(value) + self.__dict__[attr] = value + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + for elem in data: + id = utils.lowerFirst(elem.attrib['id']) + if id in self._settings: + self._settings[id]._loadData(elem) + continue + self._settings[id] = Setting(self._server, elem, self._initpath) + + def all(self): + """ Returns a list of all :class:`~plexapi.settings.Setting` objects available. """ + return list(v for id, v in sorted(self._settings.items())) + + def get(self, id): + """ Return the :class:`~plexapi.settings.Setting` object with the specified id. """ + id = utils.lowerFirst(id) + if id in self._settings: + return self._settings[id] + raise NotFound('Invalid setting id: %s' % id) + + def groups(self): + """ Returns a dict of lists for all :class:`~plexapi.settings.Setting` + objects grouped by setting group. + """ + groups = defaultdict(list) + for setting in self.all(): + groups[setting.group].append(setting) + return dict(groups) + + def group(self, group): + """ Return a list of all :class:`~plexapi.settings.Setting` objects in the specified group. + + Parameters: + group (str): Group to return all settings. + """ + return self.groups().get(group, []) + + def save(self): + """ Save any outstanding settnig changes to the :class:`~plexapi.server.PlexServer`. This + performs a full reload() of Settings after complete. + """ + params = {} + for setting in self.all(): + if setting._setValue: + log.info('Saving PlexServer setting %s = %s' % (setting.id, setting._setValue)) + params[setting.id] = quote(setting._setValue) + if not params: + raise BadRequest('No setting have been modified.') + querystr = '&'.join(['%s=%s' % (k, v) for k, v in params.items()]) + url = '%s?%s' % (self.key, querystr) + self._server.query(url, self._server._session.put) + self.reload() + + +class Setting(PlexObject): + """ Represents a single Plex setting. + + Attributes: + id (str): Setting id (or name). + label (str): Short description of what this setting is. + summary (str): Long description of what this setting is. + type (str): Setting type (text, int, double, bool). + default (str): Default value for this setting. + value (str,bool,int,float): Current value for this setting. + hidden (bool): True if this is a hidden setting. + advanced (bool): True if this is an advanced setting. + group (str): Group name this setting is categorized as. + enumValues (list,dict): List or dictionary of valis values for this setting. + """ + _bool_cast = lambda x: True if x == 'true' else False + _bool_str = lambda x: str(x).lower() + TYPES = { + 'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str}, + 'double': {'type': float, 'cast': float, 'tostr': string_type}, + 'int': {'type': int, 'cast': int, 'tostr': string_type}, + 'text': {'type': string_type, 'cast': string_type, 'tostr': string_type}, + } + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._setValue = None + self.id = data.attrib.get('id') + self.label = data.attrib.get('label') + self.summary = data.attrib.get('summary') + self.type = data.attrib.get('type') + self.default = self._cast(data.attrib.get('default')) + self.value = self._cast(data.attrib.get('value')) + self.hidden = utils.cast(bool, data.attrib.get('hidden')) + self.advanced = utils.cast(bool, data.attrib.get('advanced')) + self.group = data.attrib.get('group') + self.enumValues = self._getEnumValues(data) + + def _cast(self, value): + """ Cast the specifief value to the type of this setting. """ + if self.type != 'text': + value = utils.cast(self.TYPES.get(self.type)['cast'], value) + return value + + def _getEnumValues(self, data): + """ Returns a list of dictionary of valis value for this setting. """ + enumstr = data.attrib.get('enumValues') + if not enumstr: + return None + if ':' in enumstr: + return {self._cast(k): v for k, v in [kv.split(':') for kv in enumstr.split('|')]} + return enumstr.split('|') + + def set(self, value): + """ Set a new value for this setitng. NOTE: You must call plex.settings.save() for before + any changes to setting values are persisted to the :class:`~plexapi.server.PlexServer`. + """ + # check a few things up front + if not isinstance(value, self.TYPES[self.type]['type']): + badtype = type(value).__name__ + raise BadRequest('Invalid value for %s: a %s is required, not %s' % (self.id, self.type, badtype)) + if self.enumValues and value not in self.enumValues: + raise BadRequest('Invalid value for %s: %s not in %s' % (self.id, value, list(self.enumValues))) + # store value off to the side until we call settings.save() + tostr = self.TYPES[self.type]['tostr'] + self._setValue = tostr(value) + + def toUrl(self): + """Helper for urls""" + return '%s=%s' % (self.id, self._value or self.value) diff --git a/lib/plexapi/sync.py b/lib/plexapi/sync.py new file mode 100644 index 00000000..8ca72520 --- /dev/null +++ b/lib/plexapi/sync.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +import requests +from plexapi import utils +from plexapi.exceptions import NotFound + + +class SyncItem(object): + """ Sync Item. This doesn't current work. """ + def __init__(self, device, data, servers=None): + self._device = device + self._servers = servers + self._loadData(data) + + def _loadData(self, data): + self._data = data + self.id = utils.cast(int, data.attrib.get('id')) + self.version = utils.cast(int, data.attrib.get('version')) + self.rootTitle = data.attrib.get('rootTitle') + self.title = data.attrib.get('title') + self.metadataType = data.attrib.get('metadataType') + self.machineIdentifier = data.find('Server').get('machineIdentifier') + self.status = data.find('Status').attrib.copy() + self.MediaSettings = data.find('MediaSettings').attrib.copy() + self.policy = data.find('Policy').attrib.copy() + self.location = data.find('Location').attrib.copy() + + def server(self): + server = list(filter(lambda x: x.machineIdentifier == self.machineIdentifier, self._servers)) + if 0 == len(server): + raise NotFound('Unable to find server with uuid %s' % self.machineIdentifier) + return server[0] + + def getMedia(self): + server = self.server().connect() + key = '/sync/items/%s' % self.id + return server.fetchItems(key) + + def markAsDone(self, sync_id): + server = self.server().connect() + url = '/sync/%s/%s/files/%s/downloaded' % ( + self._device.clientIdentifier, server.machineIdentifier, sync_id) + server.query(url, method=requests.put) diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py new file mode 100644 index 00000000..b523eaeb --- /dev/null +++ b/lib/plexapi/utils.py @@ -0,0 +1,363 @@ +# -*- coding: utf-8 -*- +import logging +import os +import re +import requests +import time +import zipfile +from datetime import datetime +from getpass import getpass +from threading import Thread +from tqdm import tqdm +from plexapi import compat +from plexapi.exceptions import NotFound + +# Search Types - Plex uses these to filter specific media types when searching. +# Library Types - Populated at runtime +SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, + 'artist': 8, 'album': 9, 'track': 10, 'photo': 14} +PLEXOBJECTS = {} + + +class SecretsFilter(logging.Filter): + """ Logging filter to hide secrets. """ + + def __init__(self, secrets=None): + self.secrets = secrets or set() + + def add_secret(self, secret): + if secret is not None: + self.secrets.add(secret) + return secret + + def filter(self, record): + cleanargs = list(record.args) + for i in range(len(cleanargs)): + if isinstance(cleanargs[i], compat.string_type): + for secret in self.secrets: + cleanargs[i] = cleanargs[i].replace(secret, '') + record.args = tuple(cleanargs) + return True + + +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. + """ + 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 + + +def cast(func, value): + """ Cast the specified value to the specified type (returned by func). Currently this + only support int, float, bool. Should be extended if needed. + + Parameters: + func (func): Calback function to used cast to type (int, bool, float). + value (any): value to be cast and returned. + """ + if value is not None: + if func == bool: + return bool(int(value)) + elif func in (int, float): + try: + return func(value) + except ValueError: + return float('nan') + return func(value) + return value + + +def joinArgs(args): + """ Returns a query string (uses for HTTP URLs) where only the value is URL encoded. + Example return value: '?genre=action&type=1337'. + + Parameters: + args (dict): Arguments to include in query string. + """ + if not args: + return '' + arglist = [] + for key in sorted(args, key=lambda x: x.lower()): + value = compat.ustr(args[key]) + arglist.append('%s=%s' % (key, compat.quote(value))) + return '?%s' % '&'.join(arglist) + + +def lowerFirst(s): + return s[0].lower() + s[1:] + + +def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover + """ Returns the value at the specified attrstr location within a nexted tree of + dicts, lists, tuples, functions, classes, etc. The lookup is done recursivley + for each key in attrstr (split by by the delimiter) This function is heavily + influenced by the lookups used in Django templates. + + Parameters: + obj (any): Object to start the lookup in (dict, obj, list, tuple, etc). + attrstr (str): String to lookup (ex: 'foo.bar.baz.value') + default (any): Default value to return if not found. + delim (str): Delimiter separating keys in attrstr. + """ + try: + parts = attrstr.split(delim, 1) + attr = parts[0] + attrstr = parts[1] if len(parts) == 2 else None + if isinstance(obj, dict): + value = obj[attr] + elif isinstance(obj, list): + value = obj[int(attr)] + elif isinstance(obj, tuple): + value = obj[int(attr)] + elif isinstance(obj, object): + value = getattr(obj, attr) + if attrstr: + return rget(value, attrstr, default, delim) + return value + except: # noqa: E722 + return default + + +def searchType(libtype): + """ Returns the integer value of the library string type. + + Parameters: + libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track) + + Raises: + NotFound: Unknown libtype + """ + libtype = compat.ustr(libtype) + if libtype in [compat.ustr(v) for v in SEARCHTYPES.values()]: + return libtype + if SEARCHTYPES.get(libtype) is not None: + return SEARCHTYPES[libtype] + raise NotFound('Unknown libtype: %s' % libtype) + + +def threaded(callback, listargs): + """ Returns the result of for each set of \*args in listargs. Each call + to ', 'url': 'http://'}, + {'': {filepath, url}}, ... + """ + info = {} + for media in server.sessions(): + url = None + for part in media.iterParts(): + if media.thumb: + url = media.thumb + if part.indexes: # always use bif images if available. + url = '/library/parts/%s/indexes/%s/%s' % (part.id, part.indexes.lower(), media.viewOffset) + if url: + if filename is None: + prettyname = media._prettyfilename() + filename = 'session_transcode_%s_%s_%s' % (media.usernames[0], prettyname, int(time.time())) + url = server.transcodeImage(url, height, width, opacity, saturation) + filepath = download(url, filename=filename) + info['username'] = {'filepath': filepath, 'url': url} + return info + + +def download(url, token, filename=None, savepath=None, session=None, chunksize=4024, + unpack=False, mocked=False, showstatus=False): + """ Helper to download a thumb, videofile or other media item. Returns the local + path to the downloaded file. + + Parameters: + url (str): URL where the content be reached. + token (str): Plex auth token to include in headers. + filename (str): Filename of the downloaded file, default None. + savepath (str): Defaults to current working dir. + chunksize (int): What chunksize read/write at the time. + mocked (bool): Helper to do evertything except write the file. + unpack (bool): Unpack the zip file. + showstatus(bool): Display a progressbar. + + Example: + >>> download(a_episode.getStreamURL(), a_episode.location) + /path/to/file + """ + + from plexapi import log + # fetch the data to be saved + session = session or requests.Session() + headers = {'X-Plex-Token': token} + response = session.get(url, headers=headers, stream=True) + # make sure the savepath directory exists + savepath = savepath or os.getcwd() + compat.makedirs(savepath, exist_ok=True) + + # try getting filename from header if not specified in arguments (used for logs, db) + if not filename and response.headers.get('Content-Disposition'): + filename = re.findall(r'filename=\"(.+)\"', response.headers.get('Content-Disposition')) + filename = filename[0] if filename[0] else None + + filename = os.path.basename(filename) + fullpath = os.path.join(savepath, filename) + # append file.ext from content-type if not already there + extension = os.path.splitext(fullpath)[-1] + if not extension: + contenttype = response.headers.get('content-type') + if contenttype and 'image' in contenttype: + fullpath += contenttype.split('/')[1] + + # check this is a mocked download (testing) + if mocked: + log.debug('Mocked download %s', fullpath) + return fullpath + + # save the file to disk + log.info('Downloading: %s', fullpath) + if showstatus: # pragma: no cover + total = int(response.headers.get('content-length', 0)) + bar = tqdm(unit='B', unit_scale=True, total=total, desc=filename) + + with open(fullpath, 'wb') as handle: + for chunk in response.iter_content(chunk_size=chunksize): + handle.write(chunk) + if showstatus: + bar.update(len(chunk)) + + if showstatus: # pragma: no cover + bar.close() + # check we want to unzip the contents + if fullpath.endswith('zip') and unpack: + with zipfile.ZipFile(fullpath, 'r') as handle: + handle.extractall(savepath) + + return fullpath + + +def tag_helper(tag, items, locked=True, remove=False): + """ Simple tag helper for editing a object. """ + if not isinstance(items, list): + items = [items] + data = {} + if not remove: + for i, item in enumerate(items): + tagname = '%s[%s].tag.tag' % (tag, i) + data[tagname] = item + if remove: + tagname = '%s[].tag.tag-' % tag + data[tagname] = ','.join(items) + data['%s.locked' % tag] = 1 if locked else 0 + return data + + +def getMyPlexAccount(opts=None): # pragma: no cover + """ Helper function tries to get a MyPlex Account instance by checking + the the following locations for a username and password. This is + useful to create user-friendly command line tools. + 1. command-line options (opts). + 2. environment variables and config.ini + 3. Prompt on the command line. + """ + from plexapi import CONFIG + from plexapi.myplex import MyPlexAccount + # 1. Check command-line options + if opts and opts.username and opts.password: + print('Authenticating with Plex.tv as %s..' % opts.username) + return MyPlexAccount(opts.username, opts.password) + # 2. Check Plexconfig (environment variables and config.ini) + config_username = CONFIG.get('auth.myplex_username') + config_password = CONFIG.get('auth.myplex_password') + if config_username and config_password: + print('Authenticating with Plex.tv as %s..' % config_username) + return MyPlexAccount(config_username, config_password) + # 3. Prompt for username and password on the command line + username = input('What is your plex.tv username: ') + password = getpass('What is your plex.tv password: ') + print('Authenticating with Plex.tv as %s..' % username) + return MyPlexAccount(username, password) + + +def choose(msg, items, attr): # pragma: no cover + """ Command line helper to display a list of choices, asking the + user to choose one of the options. + """ + # Return the first item if there is only one choice + if len(items) == 1: + return items[0] + # Print all choices to the command line + print() + for index, i in enumerate(items): + name = attr(i) if callable(attr) else getattr(i, attr) + print(' %s: %s' % (index, name)) + print() + # Request choice from the user + while True: + try: + inp = input('%s: ' % msg) + if any(s in inp for s in (':', '::', '-')): + idx = slice(*map(lambda x: int(x.strip()) if x.strip() else None, inp.split(':'))) + return items[idx] + else: + return items[int(inp)] + + except (ValueError, IndexError): + pass diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py new file mode 100644 index 00000000..2d308510 --- /dev/null +++ b/lib/plexapi/video.py @@ -0,0 +1,560 @@ +# -*- coding: utf-8 -*- +from plexapi import media, utils +from plexapi.exceptions import BadRequest, NotFound +from plexapi.base import Playable, PlexPartialObject + + +class Video(PlexPartialObject): + """ Base class for all video objects including :class:`~plexapi.video.Movie`, + :class:`~plexapi.video.Show`, :class:`~plexapi.video.Season`, + :class:`~plexapi.video.Episode`. + + Attributes: + addedAt (datetime): Datetime this item was added to the library. + key (str): API URL (/library/metadata/). + lastViewedAt (datetime): Datetime item was last accessed. + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + listType (str): Hardcoded as 'audio' (useful for search filters). + ratingKey (int): Unique key identifying this item. + summary (str): Summary of the artist, track, or album. + thumb (str): URL to thumbnail image. + title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.) + titleSort (str): Title to use when sorting (defaults to title). + type (str): 'artist', 'album', or 'track'. + updatedAt (datatime): Datetime this item was updated. + viewCount (int): Count of times this item was accessed. + """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.listType = 'video' + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.key = data.attrib.get('key', '') + self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) + self.librarySectionID = data.attrib.get('librarySectionID') + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.summary = data.attrib.get('summary') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.titleSort = data.attrib.get('titleSort', self.title) + self.type = data.attrib.get('type') + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) + + @property + def isWatched(self): + """ Returns True if this video is watched. """ + return bool(self.viewCount > 0) if self.viewCount else False + + @property + def thumbUrl(self): + """ Return the first first thumbnail url starting on + the most specific thumbnail for that item. + """ + thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') + return self._server.url(thumb, includeToken=True) if thumb else None + + @property + def artUrl(self): + """ Return the first first art url starting on the most specific for that item.""" + art = self.firstAttr('art', 'grandparentArt') + return self._server.url(art, includeToken=True) if art else None + + def url(self, part): + """ Returns the full url for something. Typically used for getting a specific image. """ + return self._server.url(part, includeToken=True) if part else None + + def markWatched(self): + """ Mark video as watched. """ + key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey + self._server.query(key) + self.reload() + + def markUnwatched(self): + """ Mark video unwatched. """ + key = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey + self._server.query(key) + self.reload() + + +@utils.registerPlexObject +class Movie(Playable, Video): + """ Represents a single Movie. + + Attributes: + TAG (str): 'Video' + TYPE (str): 'movie' + art (str): Key to movie artwork (/library/metadata//art/) + audienceRating (float): Audience rating (usually from Rotten Tomatoes). + audienceRatingImage (str): Key to audience rating image (rottentomatoes://image.rating.spilled) + chapterSource (str): Chapter source (agent; media; mixed). + contentRating (str) Content rating (PG-13; NR; TV-G). + duration (int): Duration of movie in milliseconds. + guid: Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en). + originalTitle (str): Original title, often the foreign title (転々; 엽기적인 그녀). + originallyAvailableAt (datetime): Datetime movie was released. + primaryExtraKey (str) Primary extra key (/library/metadata/66351). + rating (float): Movie rating (7.9; 9.8; 8.1). + ratingImage (str): Key to rating image (rottentomatoes://image.rating.rotten). + studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment). + tagline (str): Movie tag line (Back 2 Work; Who says men can't change?). + userRating (float): User rating (2.0; 8.0). + viewOffset (int): View offset in milliseconds. + year (int): Year movie was released. + collections (List<:class:`~plexapi.media.Collection`>): List of collections this media belongs. + countries (List<:class:`~plexapi.media.Country`>): List of countries objects. + directors (List<:class:`~plexapi.media.Director`>): List of director objects. + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + media (List<:class:`~plexapi.media.Media`>): List of media objects. + producers (List<:class:`~plexapi.media.Producer`>): List of producers objects. + roles (List<:class:`~plexapi.media.Role`>): List of role objects. + writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. + chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects. + similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. + """ + TAG = 'Video' + TYPE = 'movie' + _include = ('?checkFiles=1&includeExtras=1&includeRelated=1' + '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1' + '&includeConcerts=1&includePreferences=1') + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + Playable._loadData(self, data) + + self._details_key = self.key + self._include + self.art = data.attrib.get('art') + self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) + self.audienceRatingImage = data.attrib.get('audienceRatingImage') + self.chapterSource = data.attrib.get('chapterSource') + self.contentRating = data.attrib.get('contentRating') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.guid = data.attrib.get('guid') + self.originalTitle = data.attrib.get('originalTitle') + self.originallyAvailableAt = utils.toDatetime( + data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.primaryExtraKey = data.attrib.get('primaryExtraKey') + self.rating = utils.cast(float, data.attrib.get('rating')) + self.ratingImage = data.attrib.get('ratingImage') + self.studio = data.attrib.get('studio') + self.tagline = data.attrib.get('tagline') + 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.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) + self.labels = self.findItems(data, media.Label) + self.chapters = self.findItems(data, media.Chapter) + self.similar = self.findItems(data, media.Similar) + + @property + def actors(self): + """ Alias to self.roles. """ + return self.roles + + @property + def locations(self): + """ This does not exist in plex xml response but is added to have a common + interface to get the location of the Movie/Show/Episode + """ + return [part.file for part in self.iterParts() if part] + + def subtitleStreams(self): + """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """ + streams = [] + for elem in self.media: + for part in elem.parts: + streams += part.subtitleStreams() + return streams + + def _prettyfilename(self): + # This is just for compat. + return self.title + + def download(self, savepath=None, keep_orginal_name=False, **kwargs): + """ Download video files to specified directory. + + Parameters: + savepath (str): Defaults to current working dir. + keep_orginal_name (bool): True to keep the original file name otherwise + a friendlier is generated. + **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`. + """ + filepaths = [] + locations = [i for i in self.iterParts() if i] + for location in locations: + name = location.file + if not keep_orginal_name: + title = self.title.replace(' ', '.') + name = '%s.%s' % (title, location.container) + if kwargs is not None: + url = self.getStreamURL(**kwargs) + else: + self._server.url('%s?download=1' % location.key) + filepath = utils.download(url, self._server._token, filename=name, + savepath=savepath, session=self._server._session) + if filepath: + filepaths.append(filepath) + return filepaths + + +@utils.registerPlexObject +class Show(Video): + """ Represents a single Show (including all seasons and episodes). + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'show' + art (str): Key to show artwork (/library/metadata//art/) + banner (str): Key to banner artwork (/library/metadata//art/) + childCount (int): Unknown. + contentRating (str) Content rating (PG-13; NR; TV-G). + duration (int): Duration of show in milliseconds. + guid (str): Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en). + index (int): Plex index (?) + leafCount (int): Unknown. + locations (list): List of locations paths. + originallyAvailableAt (datetime): Datetime show was released. + rating (float): Show rating (7.9; 9.8; 8.1). + studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment). + theme (str): Key to theme resource (/library/metadata//theme/) + viewedLeafCount (int): Unknown. + year (int): Year the show was released. + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + roles (List<:class:`~plexapi.media.Role`>): List of role objects. + similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. + """ + TAG = 'Directory' + TYPE = 'show' + + def __iter__(self): + for season in self.seasons(): + yield season + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + # fix key if loaded from search + self.key = self.key.replace('/children', '') + self.art = data.attrib.get('art') + self.banner = data.attrib.get('banner') + self.childCount = utils.cast(int, data.attrib.get('childCount')) + self.contentRating = data.attrib.get('contentRating') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.guid = data.attrib.get('guid') + self.index = data.attrib.get('index') + self.leafCount = utils.cast(int, data.attrib.get('leafCount')) + self.locations = self.listAttrs(data, 'path', etag='Location') + self.originallyAvailableAt = utils.toDatetime( + data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.rating = utils.cast(float, data.attrib.get('rating')) + self.studio = data.attrib.get('studio') + 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.findItems(data, media.Genre) + self.roles = self.findItems(data, media.Role) + self.labels = self.findItems(data, media.Label) + self.similar = self.findItems(data, media.Similar) + + @property + def actors(self): + """ Alias to self.roles. """ + return self.roles + + @property + def isWatched(self): + """ Returns True if this show is fully watched. """ + return bool(self.viewedLeafCount == self.leafCount) + + def seasons(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Season` objects. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, **kwargs) + + def season(self, title=None): + """ Returns the season with the specified title or number. + + Parameters: + title (str or int): Title or Number of the season to return. + """ + if isinstance(title, int): + title = 'Season %s' % title + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItem(key, etag='Directory', title__iexact=title) + + def episodes(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Episode` objects. """ + key = '/library/metadata/%s/allLeaves' % self.ratingKey + return self.fetchItems(key, **kwargs) + + def episode(self, title=None, season=None, episode=None): + """ Find a episode using a title or season and episode. + + Parameters: + title (str): Title of the episode to return + season (int): Season number (default:None; required if title not specified). + episode (int): Episode number (default:None; required if title not specified). + + Raises: + BadRequest: If season and episode is missing. + NotFound: If the episode is missing. + """ + if title: + key = '/library/metadata/%s/allLeaves' % self.ratingKey + return self.fetchItem(key, title__iexact=title) + elif season and episode: + results = [i for i in self.episodes() if i.seasonNumber == season and i.index == episode] + if results: + return results[0] + raise NotFound('Couldnt find %s S%s E%s' % (self.title, season, episode)) + raise BadRequest('Missing argument: title or season and episode are required') + + def watched(self): + """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ + return self.episodes(viewCount__gt=0) + + def unwatched(self): + """ Returns list of unwatched :class:`~plexapi.video.Episode` objects. """ + return self.episodes(viewCount=0) + + def get(self, title=None, season=None, episode=None): + """ Alias to :func:`~plexapi.video.Show.episode()`. """ + return self.episode(title, season, episode) + + def download(self, savepath=None, keep_orginal_name=False, **kwargs): + """ Download video files to specified directory. + + Parameters: + savepath (str): Defaults to current working dir. + keep_orginal_name (bool): True to keep the original file name otherwise + a friendlier is generated. + **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`. + """ + filepaths = [] + for episode in self.episodes(): + filepaths += episode.download(savepath, keep_orginal_name, **kwargs) + return filepaths + + +@utils.registerPlexObject +class Season(Video): + """ Represents a single Show Season (including all episodes). + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'season' + leafCount (int): Number of episodes in season. + index (int): Season number. + parentKey (str): Key to this seasons :class:`~plexapi.video.Show`. + parentRatingKey (int): Unique key for this seasons :class:`~plexapi.video.Show`. + parentTitle (str): Title of this seasons :class:`~plexapi.video.Show`. + viewedLeafCount (int): Number of watched episodes in season. + """ + TAG = 'Directory' + TYPE = 'season' + + def __iter__(self): + for episode in self.episodes(): + yield episode + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + # fix key if loaded from search + self.key = self.key.replace('/children', '') + self.leafCount = utils.cast(int, data.attrib.get('leafCount')) + self.index = utils.cast(int, data.attrib.get('index')) + self.parentKey = data.attrib.get('parentKey') + self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self.parentTitle = data.attrib.get('parentTitle') + self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) + + def __repr__(self): + return '<%s>' % ':'.join([p for p in [ + self.__class__.__name__, + self.key.replace('/library/metadata/', '').replace('/children', ''), + '%s-s%s' % (self.parentTitle.replace(' ', '-')[:20], self.seasonNumber), + ] if p]) + + @property + def isWatched(self): + """ Returns True if this season is fully watched. """ + return bool(self.viewedLeafCount == self.leafCount) + + @property + def seasonNumber(self): + """ Returns season number. """ + return self.index + + def episodes(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Episode` objects. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, **kwargs) + + def episode(self, title=None, episode=None): + """ Returns the episode with the given title or number. + + Parameters: + title (str): Title of the episode to return. + episode (int): Episode number (default:None; required if title not specified). + """ + if not title and not episode: + raise BadRequest('Missing argument, you need to use title or episode.') + key = '/library/metadata/%s/children' % self.ratingKey + if title: + return self.fetchItem(key, title=title) + return self.fetchItem(key, seasonNumber=self.index, index=episode) + + def get(self, title=None, episode=None): + """ Alias to :func:`~plexapi.video.Season.episode()`. """ + return self.episode(title, episode) + + def show(self): + """ Return this seasons :func:`~plexapi.video.Show`.. """ + return self.fetchItem(self.parentKey) + + def watched(self): + """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ + return self.episodes(watched=True) + + def unwatched(self): + """ Returns list of unwatched :class:`~plexapi.video.Episode` objects. """ + return self.episodes(watched=False) + + def download(self, savepath=None, keep_orginal_name=False, **kwargs): + """ Download video files to specified directory. + + Parameters: + savepath (str): Defaults to current working dir. + keep_orginal_name (bool): True to keep the original file name otherwise + a friendlier is generated. + **kwargs: Additional options passed into :func:`~plexapi.base.PlexObject.getStreamURL()`. + """ + filepaths = [] + for episode in self.episodes(): + filepaths += episode.download(savepath, keep_orginal_name, **kwargs) + return filepaths + + +@utils.registerPlexObject +class Episode(Playable, Video): + """ Represents a single Shows Episode. + + Attributes: + TAG (str): 'Video' + TYPE (str): 'episode' + art (str): Key to episode artwork (/library/metadata//art/) + chapterSource (str): Unknown (media). + contentRating (str) Content rating (PG-13; NR; TV-G). + duration (int): Duration of episode in milliseconds. + grandparentArt (str): Key to this episodes :class:`~plexapi.video.Show` artwork. + grandparentKey (str): Key to this episodes :class:`~plexapi.video.Show`. + grandparentRatingKey (str): Unique key for this episodes :class:`~plexapi.video.Show`. + grandparentTheme (str): Key to this episodes :class:`~plexapi.video.Show` theme. + grandparentThumb (str): Key to this episodes :class:`~plexapi.video.Show` thumb. + grandparentTitle (str): Title of this episodes :class:`~plexapi.video.Show`. + guid (str): Plex GUID (com.plexapp.agents.imdb://tt4302938?lang=en). + index (int): Episode number. + originallyAvailableAt (datetime): Datetime episode was released. + parentIndex (str): Season number of episode. + parentKey (str): Key to this episodes :class:`~plexapi.video.Season`. + parentRatingKey (int): Unique key for this episodes :class:`~plexapi.video.Season`. + parentThumb (str): Key to this episodes thumbnail. + parentTitle (str): Name of this episode's season + title (str): Name of this Episode + rating (float): Movie rating (7.9; 9.8; 8.1). + viewOffset (int): View offset in milliseconds. + year (int): Year episode was released. + directors (List<:class:`~plexapi.media.Director`>): List of director objects. + media (List<:class:`~plexapi.media.Media`>): List of media objects. + writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. + """ + TAG = 'Video' + TYPE = 'episode' + _include = ('?checkFiles=1&includeExtras=1&includeRelated=1' + '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1' + '&includeConcerts=1&includePreferences=1') + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + Video._loadData(self, data) + Playable._loadData(self, data) + self._details_key = self.key + self._include + self._seasonNumber = None # cached season number + self.art = data.attrib.get('art') + self.chapterSource = data.attrib.get('chapterSource') + self.contentRating = data.attrib.get('contentRating') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.grandparentArt = data.attrib.get('grandparentArt') + self.grandparentKey = data.attrib.get('grandparentKey') + self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) + self.grandparentTheme = data.attrib.get('grandparentTheme') + self.grandparentThumb = data.attrib.get('grandparentThumb') + self.grandparentTitle = data.attrib.get('grandparentTitle') + self.guid = data.attrib.get('guid') + self.index = utils.cast(int, data.attrib.get('index')) + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.parentIndex = data.attrib.get('parentIndex') + self.parentKey = data.attrib.get('parentKey') + self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self.parentThumb = data.attrib.get('parentThumb') + self.parentTitle = data.attrib.get('parentTitle') + self.title = data.attrib.get('title') + 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.findItems(data, media.Director) + self.media = self.findItems(data, media.Media) + self.writers = self.findItems(data, media.Writer) + self.labels = self.findItems(data, media.Label) + self.collections = self.findItems(data, media.Collection) + self.chapters = self.findItems(data, media.Chapter) + + def __repr__(self): + return '<%s>' % ':'.join([p for p in [ + self.__class__.__name__, + self.key.replace('/library/metadata/', '').replace('/children', ''), + '%s-%s' % (self.grandparentTitle.replace(' ', '-')[:20], self.seasonEpisode), + ] if p]) + + def _prettyfilename(self): + """ Returns a human friendly filename. """ + return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode) + + @property + def locations(self): + """ This does not exist in plex xml response but is added to have a common + interface to get the location of the Movie/Show + """ + return [part.file for part in self.iterParts() if part] + + @property + def seasonNumber(self): + """ Returns this episodes season number. """ + if self._seasonNumber is None: + self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber + return utils.cast(int, self._seasonNumber) + + @property + def seasonEpisode(self): + """ Returns the s00e00 string containing the season and episode. """ + return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.index).zfill(2)) + + def season(self): + """" Return this episodes :func:`~plexapi.video.Season`.. """ + return self.fetchItem(self.parentKey) + + def show(self): + """" Return this episodes :func:`~plexapi.video.Show`.. """ + return self.fetchItem(self.grandparentKey)