From f497c11d73e965c4825a3f5cf87508b2dc3f584a Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 24 Jan 2021 19:53:39 -0800 Subject: [PATCH] Update plexapi to 4.3.0 --- lib/plexapi/__init__.py | 2 +- lib/plexapi/audio.py | 265 +++++++------ lib/plexapi/base.py | 45 ++- lib/plexapi/client.py | 102 +++-- lib/plexapi/compat.py | 118 ------ lib/plexapi/config.py | 3 +- lib/plexapi/gdm.py | 4 +- lib/plexapi/library.py | 798 +++++++++++++++++++++++++++++---------- lib/plexapi/media.py | 407 ++++++++++++-------- lib/plexapi/myplex.py | 107 ++++-- lib/plexapi/photo.py | 245 ++++++++---- lib/plexapi/playlist.py | 65 +++- lib/plexapi/playqueue.py | 324 +++++++++++++--- lib/plexapi/server.py | 325 ++++++++++++++-- lib/plexapi/settings.py | 27 +- lib/plexapi/sync.py | 4 +- lib/plexapi/utils.py | 90 ++++- lib/plexapi/video.py | 495 ++++++++++++++---------- 18 files changed, 2365 insertions(+), 1061 deletions(-) delete mode 100644 lib/plexapi/compat.py diff --git a/lib/plexapi/__init__.py b/lib/plexapi/__init__.py index 95a8dfc0..2f222236 100644 --- a/lib/plexapi/__init__.py +++ b/lib/plexapi/__init__.py @@ -15,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH) # PlexAPI Settings PROJECT = 'PlexAPI' -VERSION = '3.6.0' +VERSION = '4.3.0' TIMEOUT = CONFIG.get('plexapi.timeout', 30, int) X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int) X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool) diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index 4be482f2..53f7d9bd 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -1,31 +1,39 @@ # -*- coding: utf-8 -*- -from plexapi import media, utils +from urllib.parse import quote_plus + +from plexapi import library, media, utils from plexapi.base import Playable, PlexPartialObject -from plexapi.compat import quote_plus +from plexapi.exceptions import BadRequest class Audio(PlexPartialObject): - """ Base class for audio :class:`~plexapi.audio.Artist`, :class:`~plexapi.audio.Album` - and :class:`~plexapi.audio.Track` objects. + """ Base class for all audio objects including :class:`~plexapi.audio.Artist`, + :class:`~plexapi.audio.Album`, and :class:`~plexapi.audio.Track`. Attributes: - addedAt (datetime): Datetime this item was added to the library. - art (str): URL to artwork image. + addedAt (datetime): Datetime the item was added to the library. + art (str): URL to artwork image (/library/metadata//art/). artBlurHash (str): BlurHash string for artwork image. - index (sting): Index Number (often the track number). + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the artist, album, or track (plex://artist/5d07bcb0403c64029053ac4c). + index (int): Plex index number (often the track number). key (str): API URL (/library/metadata/). - lastViewedAt (datetime): Datetime item was last accessed. + lastViewedAt (datetime): Datetime the item was last played. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. 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. + moods (List<:class:`~plexapi.media.Mood`>): List of mood objects. + ratingKey (int): Unique key identifying the item. + summary (str): Summary of the artist, album, or track. + thumb (str): URL to thumbnail image (/library/metadata//thumb/). thumbBlurHash (str): BlurHash string for thumbnail image. - title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.) + title (str): Name of the artist, album, or track (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. + updatedAt (datatime): Datetime the item was updated. + userRating (float): Rating of the track (0.0 - 10.0) equaling (0 stars - 5 stars). + viewCount (int): Count of times the item was played. """ METADATA_TYPE = 'track' @@ -33,16 +41,19 @@ class Audio(PlexPartialObject): 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.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') - self.index = data.attrib.get('index') - self.key = data.attrib.get('key') + self.fields = self.findItems(data, media.Field) + self.guid = data.attrib.get('guid') + self.index = utils.cast(int, 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.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.listType = 'audio' + self.moods = self.findItems(data, media.Mood) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.summary = data.attrib.get('summary') self.thumb = data.attrib.get('thumb') @@ -51,6 +62,7 @@ class Audio(PlexPartialObject): self.titleSort = data.attrib.get('titleSort', self.title) self.type = data.attrib.get('type') self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + self.userRating = utils.cast(float, data.attrib.get('userRating', 0)) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) @property @@ -66,7 +78,7 @@ class Audio(PlexPartialObject): 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. """ + """ Returns the full URL for the audio item. Typically used for getting a specific track. """ return self._server.url(part, includeToken=True) if part else None def _defaultSyncTitle(self): @@ -112,17 +124,18 @@ class Audio(PlexPartialObject): @utils.registerPlexObject class Artist(Audio): - """ Represents a single audio artist. + """ Represents a single Artist. Attributes: TAG (str): 'Directory' TYPE (str): 'artist' - 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) + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + countries (List<:class:`~plexapi.media.Country`>): List country objects. + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. key (str): API URL (/library/metadata/). - location (str): Filepath this artist is found on disk. - similar (list): List of :class:`~plexapi.media.Similar` artists. + locations (List): List of folder paths where the artist is found on disk. + similar (List<:class:`~plexapi.media.Similar`>): List of similar objects. + styles (List<:class:`~plexapi.media.Style`>): List of style objects. """ TAG = 'Directory' TYPE = 'artist' @@ -130,55 +143,70 @@ class Artist(Audio): def _loadData(self, data): """ Load attribute values from Plex XML response. """ Audio._loadData(self, data) - self.key = self.key.replace('/children', '') # FIX_BUG_50 - self.guid = data.attrib.get('guid') - self.locations = self.listAttrs(data, 'path', etag='Location') - self.countries = self.findItems(data, media.Country) - self.fields = self.findItems(data, media.Field) - self.genres = self.findItems(data, media.Genre) - self.similar = self.findItems(data, media.Similar) self.collections = self.findItems(data, media.Collection) - self.moods = self.findItems(data, media.Mood) + self.countries = self.findItems(data, media.Country) + self.genres = self.findItems(data, media.Genre) + self.key = self.key.replace('/children', '') # FIX_BUG_50 + self.locations = self.listAttrs(data, 'path', etag='Location') + self.similar = self.findItems(data, media.Similar) self.styles = self.findItems(data, media.Style) def __iter__(self): for album in self.albums(): yield album + def hubs(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. """ + data = self._server.query(self._details_key) + directory = data.find('Directory') + if directory: + related = directory.find('Related') + if related: + return self.findItems(related, library.Hub) + 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) + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItem(key, Album, 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) + """ Returns a list of :class:`~plexapi.audio.Album` objects by the artist. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, Album, **kwargs) - def track(self, title): + def track(self, title=None, album=None, track=None): """ Returns the :class:`~plexapi.audio.Track` that matches the specified title. Parameters: title (str): Title of the track to return. + album (str): Album name (default: None; required if title not specified). + track (int): Track number (default: None; required if title not specified). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If title or album and track parameters are missing. """ - key = '%s/allLeaves' % self.key - return self.fetchItem(key, title__iexact=title) + key = '/library/metadata/%s/allLeaves' % self.ratingKey + if title is not None: + return self.fetchItem(key, Track, title__iexact=title) + elif album is not None and track is not None: + return self.fetchItem(key, Track, parentTitle__iexact=album, index=track) + raise BadRequest('Missing argument: title or album and track are required') 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) + """ Returns a list of :class:`~plexapi.audio.Track` objects by the artist. """ + key = '/library/metadata/%s/allLeaves' % self.ratingKey + return self.fetchItems(key, Track, **kwargs) - def get(self, title): + def get(self, title=None, album=None, track=None): """ Alias of :func:`~plexapi.audio.Artist.track`. """ - return self.track(title) + return self.track(title, album, track) def download(self, savepath=None, keep_original_name=False, **kwargs): - """ Downloads all tracks for this artist to the specified location. + """ Downloads all tracks for the artist to the specified location. Parameters: savepath (str): Title of the track to return. @@ -199,76 +227,89 @@ class Artist(Audio): @utils.registerPlexObject class Album(Audio): - """ Represents a single audio album. + """ Represents a single Album. Attributes: TAG (str): 'Directory' TYPE (str): 'album' - genres (list): List of :class:`~plexapi.media.Genre` objects this album respresents. + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. 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. + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + leafCount (int): Number of items in the album view. + loudnessAnalysisVersion (int): The Plex loudness analysis version level. + originallyAvailableAt (datetime): Datetime the album was released. + parentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c). + parentKey (str): API URL of the album artist (/library/metadata/). + parentRatingKey (int): Unique key identifying the album artist. + parentThumb (str): URL to album artist thumbnail image (/library/metadata//thumb/). + parentTitle (str): Name of the album artist. + rating (float): Album rating (7.9; 9.8; 8.1). + studio (str): Studio that released the album. + styles (List<:class:`~plexapi.media.Style`>): List of style objects. + viewedLeafCount (int): Number of items marked as played in the album view. + year (int): Year the 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.guid = data.attrib.get('guid') + self.collections = self.findItems(data, media.Collection) + self.genres = self.findItems(data, media.Genre) + self.key = self.key.replace('/children', '') # FIX_BUG_50 + self.labels = self.findItems(data, media.Label) self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion')) - self.key = self.key.replace('/children', '') # FIX_BUG_50 self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.parentGuid = data.attrib.get('parentGuid') self.parentKey = data.attrib.get('parentKey') - self.parentRatingKey = data.attrib.get('parentRatingKey') + self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.rating = utils.cast(float, data.attrib.get('rating')) self.studio = data.attrib.get('studio') + self.styles = self.findItems(data, media.Style) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) - self.collections = self.findItems(data, media.Collection) - self.fields = self.findItems(data, media.Field) - self.genres = self.findItems(data, media.Genre) - self.labels = self.findItems(data, media.Label) - self.moods = self.findItems(data, media.Mood) - self.styles = self.findItems(data, media.Style) - def track(self, title): + def __iter__(self): + for track in self.tracks(): + yield track + + def track(self, title=None, track=None): """ Returns the :class:`~plexapi.audio.Track` that matches the specified title. Parameters: title (str): Title of the track to return. + track (int): Track number (default: None; required if title not specified). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing. """ - key = '%s/children' % self.key - return self.fetchItem(key, title__iexact=title) + key = '/library/metadata/%s/children' % self.ratingKey + if title is not None: + return self.fetchItem(key, Track, title__iexact=title) + elif track is not None: + return self.fetchItem(key, Track, parentTitle__iexact=self.title, index=track) + raise BadRequest('Missing argument: title or track is required') 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) + """ Returns a list of :class:`~plexapi.audio.Track` objects in the album. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, Track, **kwargs) - def get(self, title): + def get(self, title=None, track=None): """ Alias of :func:`~plexapi.audio.Album.track`. """ - return self.track(title) + return self.track(title, track) def artist(self): - """ Return :func:`~plexapi.audio.Artist` of this album. """ + """ Return the album's :class:`~plexapi.audio.Artist`. """ return self.fetchItem(self.parentKey) def download(self, savepath=None, keep_original_name=False, **kwargs): - """ Downloads all tracks for this artist to the specified location. + """ Downloads all tracks for the artist to the specified location. Parameters: savepath (str): Title of the track to return. @@ -292,37 +333,32 @@ class Album(Audio): @utils.registerPlexObject class Track(Audio, Playable): - """ Represents a single audio track. + """ Represents a single Track. Attributes: TAG (str): 'Directory' TYPE (str): 'track' - chapterSource (TYPE): Unknown - duration (int): Length of this album in seconds. - grandparentArt (str): Album artist artwork. - grandparentKey (str): Album artist API URL. - grandparentRatingKey (str): Unique key identifying album artist. - grandparentThumb (str): URL to album artist thumbnail image. - grandparentTitle (str): Name of the album 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): Track artist. + chapterSource (str): Unknown + duration (int): Length of the track in milliseconds. + grandparentArt (str): URL to album artist artwork (/library/metadata//art/). + grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c). + grandparentKey (str): API URL of the album artist (/library/metadata/). + grandparentRatingKey (int): Unique key identifying the album artist. + grandparentThumb (str): URL to album artist thumbnail image + (/library/metadata//thumb/). + grandparentTitle (str): Name of the album artist for the track. + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originalTitle (str): The original title of the track (eg. a different language). + parentGuid (str): Plex GUID for the album (plex://album/5d07cd8e403c640290f180f9). 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). + parentKey (str): API URL of the album (/library/metadata/). + parentRatingKey (int): Unique key identifying the album. + parentThumb (str): URL to album thumbnail image (/library/metadata//thumb/). + parentTitle (str): Name of the album for the track. + primaryExtraKey (str) API URL for the primary extra for the track. + ratingCount (int): Number of ratings contributing to the rating score. + viewOffset (int): View offset in milliseconds. + year (int): Year the track was released. """ TAG = 'Track' TYPE = 'track' @@ -336,45 +372,44 @@ class Track(Audio, Playable): self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentGuid = data.attrib.get('grandparentGuid') self.grandparentKey = data.attrib.get('grandparentKey') - self.grandparentRatingKey = data.attrib.get('grandparentRatingKey') + self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) self.grandparentThumb = data.attrib.get('grandparentThumb') self.grandparentTitle = data.attrib.get('grandparentTitle') - self.guid = data.attrib.get('guid') + self.media = self.findItems(data, media.Media) self.originalTitle = data.attrib.get('originalTitle') self.parentGuid = data.attrib.get('parentGuid') self.parentIndex = data.attrib.get('parentIndex') self.parentKey = data.attrib.get('parentKey') - self.parentRatingKey = data.attrib.get('parentRatingKey') + self.parentRatingKey = utils.cast(int, 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) - self.fields = self.findItems(data, media.Field) 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 the track's :class:`~plexapi.audio.Album`. """ return self.fetchItem(self.parentKey) def artist(self): - """ Return this track's :class:`~plexapi.audio.Artist`. """ + """ Return the track's :class:`~plexapi.audio.Artist`. """ return self.fetchItem(self.grandparentKey) @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 Track + interface to get the locations of the track. + + Retruns: + List of file paths where the track is found on disk. """ return [part.file for part in self.iterParts() if part] - + def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title) diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index e7cf8e53..4d9f397f 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- import re +import weakref +from urllib.parse import quote_plus, urlencode 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 @@ -35,15 +36,17 @@ class PlexObject(object): 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). + parent (:class:`~plexapi.base.PlexObject`): The parent object that this object is built from (optional). """ TAG = None # xml element tag TYPE = None # xml element type key = None # plex relative url - def __init__(self, server, data, initpath=None): + def __init__(self, server, data, initpath=None, parent=None): self._server = server self._data = data self._initpath = initpath or self.key + self._parent = weakref.ref(parent) if parent else None if data is not None: self._loadData(data) self._details_key = self._buildDetailsKey() @@ -54,8 +57,8 @@ class PlexObject(object): 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__: + # Don't overwrite an attr with None or [] unless it's a private variable + if value not in [None, []] or attr.startswith('_') or attr not in self.__dict__: self.__dict__[attr] = value def _clean(self, value): @@ -63,6 +66,8 @@ class PlexObject(object): if value: value = str(value).replace('/library/metadata/', '') value = value.replace('/children', '') + value = value.replace('/accounts/', '') + value = value.replace('/devices/', '') return value.replace(' ', '-')[:20] def _buildItem(self, elem, cls=None, initpath=None): @@ -70,9 +75,9 @@ class PlexObject(object): # cls is specified, build the object and return initpath = initpath or self._initpath if cls is not None: - return cls(self._server, elem, initpath) + return cls(self._server, elem, initpath, parent=self) # cls is not specified, try looking it up in PLEXOBJECTS - etype = elem.attrib.get('type', elem.attrib.get('streamType')) + etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type'))) 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__) @@ -95,7 +100,7 @@ class PlexObject(object): or disable each parameter individually by setting it to False or 0. """ details_key = self.key - if hasattr(self, '_INCLUDES'): + if details_key and hasattr(self, '_INCLUDES'): includes = {} for k, v in self._INCLUDES.items(): value = kwargs.get(k, v) @@ -105,6 +110,21 @@ class PlexObject(object): details_key += '?' + urlencode(sorted(includes.items())) return details_key + def _isChildOf(self, **kwargs): + """ Returns True if this object is a child of the given attributes. + This will search the parent objects all the way to the top. + + Parameters: + **kwargs (dict): The attributes and values to search for in the parent objects. + See all possible `**kwargs*` in :func:`~plexapi.base.PlexObject.fetchItem`. + """ + obj = self + while obj._parent is not None: + obj = obj._parent() + if obj._checkAttrs(obj._data, **kwargs): + return True + return False + 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 @@ -212,6 +232,7 @@ class PlexObject(object): return value def listAttrs(self, data, attr, **kwargs): + """ Return a list of values from matching attribute. """ results = [] for elem in data: kwargs['%s__exists' % attr] = True @@ -350,7 +371,7 @@ class PlexPartialObject(PlexObject): } def __eq__(self, other): - return other is not None and self.key == other.key + return other not in [None, []] and self.key == other.key def __hash__(self): return hash(repr(self)) @@ -391,6 +412,8 @@ class PlexPartialObject(PlexObject): 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. + * Generate intro video markers: Detects show intros, exposing the + 'Skip Intro' button in clients. """ key = '/%s/analyze' % self.key.lstrip('/') self._server.query(key, method=self._server._session.put) @@ -663,6 +686,7 @@ class Playable(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). + playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items). """ def _loadData(self, data): @@ -674,6 +698,7 @@ class Playable(object): self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt')) # history self.accountID = utils.cast(int, data.attrib.get('accountID')) # history self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist + self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue def getStreamURL(self, **params): """ Returns a stream url that may be used by external applications such as VLC. @@ -684,7 +709,7 @@ class Playable(object): offset, copyts, protocol, mediaIndex, platform. Raises: - :exc:`plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL. + :exc:`~plexapi.exceptions.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) @@ -698,7 +723,7 @@ class Playable(object): '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 + 'videoResolution': vr if re.match(r'^\d+x\d+$', vr) else None } # remove None values params = {k: v for k, v in params.items() if v is not None} diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py index 8d077238..44f1da52 100644 --- a/lib/plexapi/client.py +++ b/lib/plexapi/client.py @@ -1,10 +1,10 @@ # -*- coding: utf-8 -*- import time +from xml.etree import ElementTree import requests from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils from plexapi.base import PlexObject -from plexapi.compat import ElementTree from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported from plexapi.playqueue import PlayQueue from requests.status_codes import _codes as codes @@ -69,7 +69,9 @@ class PlexClient(PlexObject): self._proxyThroughServer = False self._commandId = 0 self._last_call = 0 - if not any([data, initpath, baseurl, token]): + self._timeline_cache = [] + self._timeline_cache_timestamp = 0 + if not any([data is not None, 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: @@ -138,7 +140,7 @@ class PlexClient(PlexObject): value (bool): Enable or disable proxying (optional, default True). Raises: - :exc:`plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server. + :exc:`~plexapi.exceptions.Unsupported`: Cannot use client proxy with unknown server. """ if server: self._server = server @@ -181,7 +183,7 @@ class PlexClient(PlexObject): **params (dict): Additional GET parameters to include with the command. Raises: - :exc:`plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability. + :exc:`~plexapi.exceptions.Unsupported`: When we detect the client doesn't support this capability. """ command = command.strip('/') controller = command.split('/')[0] @@ -195,10 +197,11 @@ class PlexClient(PlexObject): # Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244 t = time.time() - if t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'): - url = '/player/timeline/poll?wait=0&commandID=%s' % self._nextCommandId() - query(url, headers=headers) + if command == 'timeline/poll': self._last_call = t + elif t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'): + self._last_call = t + self.sendCommand(ClientTimeline.key, wait=0) params['commandID'] = self._nextCommandId() key = '/player/%s%s' % (command, utils.joinArgs(params)) @@ -296,7 +299,7 @@ class PlexClient(PlexObject): **params (dict): Additional GET parameters to include with the command. Raises: - :exc:`plexapi.exceptions.Unsupported`: When no PlexServer specified in this object. + :exc:`~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.') @@ -466,7 +469,7 @@ class PlexClient(PlexObject): also: https://github.com/plexinc/plex-media-player/wiki/Remote-control-API#modified-commands Raises: - :exc:`plexapi.exceptions.Unsupported`: When no PlexServer specified in this object. + :exc:`~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.') @@ -485,15 +488,6 @@ class PlexClient(PlexObject): if mediatype == "audio": mediatype = "music" - if self.product != 'OpenPHT': - try: - self.sendCommand('timeline/subscribe', port=server_port, 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, @@ -548,20 +542,68 @@ class PlexClient(PlexObject): # ------------------- # Timeline Commands - def timeline(self, wait=1): - """ Poll the current timeline and return the XML response. """ - return self.sendCommand('timeline/poll', wait=wait) + def timelines(self, wait=0): + """Poll the client's timelines, create, and return timeline objects. + Some clients may not always respond to timeline requests, believe this + to be a Plex bug. + """ + t = time.time() + if t - self._timeline_cache_timestamp > 1: + self._timeline_cache_timestamp = t + timelines = self.sendCommand(ClientTimeline.key, wait=wait) or [] + self._timeline_cache = [ClientTimeline(self, data) for data in timelines] - def isPlayingMedia(self, includePaused=False): - """ Returns True if any media is currently playing. + return self._timeline_cache + + @property + def timeline(self): + """Returns the active timeline object.""" + return next((x for x in self.timelines() if x.state != 'stopped'), None) + + def isPlayingMedia(self, includePaused=True): + """Returns True if any media is currently playing. Parameters: includePaused (bool): Set True to treat currently paused items - as playing (optional; default True). + as playing (optional; default True). """ - for mediatype in self.timeline(wait=0): - if mediatype.get('state') == 'playing': - return True - if includePaused and mediatype.get('state') == 'paused': - return True - return False + state = getattr(self.timeline, "state", None) + return bool(state == 'playing' or (includePaused and state == 'paused')) + + +class ClientTimeline(PlexObject): + """Get the timeline's attributes.""" + + key = 'timeline/poll' + + def _loadData(self, data): + self._data = data + self.address = data.attrib.get('address') + self.audioStreamId = utils.cast(int, data.attrib.get('audioStreamId')) + self.autoPlay = utils.cast(bool, data.attrib.get('autoPlay')) + self.containerKey = data.attrib.get('containerKey') + self.controllable = data.attrib.get('controllable') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.itemType = data.attrib.get('itemType') + self.key = data.attrib.get('key') + self.location = data.attrib.get('location') + self.machineIdentifier = data.attrib.get('machineIdentifier') + self.partCount = utils.cast(int, data.attrib.get('partCount')) + self.partIndex = utils.cast(int, data.attrib.get('partIndex')) + self.playQueueID = utils.cast(int, data.attrib.get('playQueueID')) + self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) + self.playQueueVersion = utils.cast(int, data.attrib.get('playQueueVersion')) + self.port = utils.cast(int, data.attrib.get('port')) + self.protocol = data.attrib.get('protocol') + self.providerIdentifier = data.attrib.get('providerIdentifier') + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.repeat = utils.cast(bool, data.attrib.get('repeat')) + self.seekRange = data.attrib.get('seekRange') + self.shuffle = utils.cast(bool, data.attrib.get('shuffle')) + self.state = data.attrib.get('state') + self.subtitleColor = data.attrib.get('subtitleColor') + self.subtitlePosition = data.attrib.get('subtitlePosition') + self.subtitleSize = utils.cast(int, data.attrib.get('subtitleSize')) + self.time = utils.cast(int, data.attrib.get('time')) + self.type = data.attrib.get('type') + self.volume = utils.cast(int, data.attrib.get('volume')) diff --git a/lib/plexapi/compat.py b/lib/plexapi/compat.py deleted file mode 100644 index 0d52c70e..00000000 --- a/lib/plexapi/compat.py +++ /dev/null @@ -1,118 +0,0 @@ -# -*- coding: utf-8 -*- -# Python 2/3 compatability -# Always try Py3 first -import os -import sys -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, quote -except ImportError: - from urllib import quote_plus, quote - -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 - - -def which(cmd, mode=os.F_OK | os.X_OK, path=None): - """Given a command, mode, and a PATH string, return the path which - conforms to the given mode on the PATH, or None if there is no such - file. - - `mode` defaults to os.F_OK | os.X_OK. `path` defaults to the result - of os.environ.get("PATH"), or can be overridden with a custom search - path. - - Copied from https://hg.python.org/cpython/file/default/Lib/shutil.py - """ - # Check that a given file can be accessed with the correct mode. - # Additionally check that `file` is not a directory, as on Windows - # directories pass the os.access check. - def _access_check(fn, mode): - return (os.path.exists(fn) and os.access(fn, mode) - and not os.path.isdir(fn)) - - # If we're given a path with a directory part, look it up directly rather - # than referring to PATH directories. This includes checking relative to the - # current directory, e.g. ./script - if os.path.dirname(cmd): - if _access_check(cmd, mode): - return cmd - return None - - if path is None: - path = os.environ.get("PATH", os.defpath) - if not path: - return None - path = path.split(os.pathsep) - - if sys.platform == "win32": - # The current directory takes precedence on Windows. - if not os.curdir in path: - path.insert(0, os.curdir) - - # PATHEXT is necessary to check on Windows. - pathext = os.environ.get("PATHEXT", "").split(os.pathsep) - # See if the given file matches any of the expected path extensions. - # This will allow us to short circuit when given "python.exe". - # If it does match, only test that one, otherwise we have to try - # others. - if any(cmd.lower().endswith(ext.lower()) for ext in pathext): - files = [cmd] - else: - files = [cmd + ext for ext in pathext] - else: - # On other platforms you don't have things like PATHEXT to tell you - # what file suffixes are executable, so just pass on cmd as-is. - files = [cmd] - - seen = set() - for dir in path: - normdir = os.path.normcase(dir) - if not normdir in seen: - seen.add(normdir) - for thefile in files: - name = os.path.join(dir, thefile) - if _access_check(name, mode): - return name - return None diff --git a/lib/plexapi/config.py b/lib/plexapi/config.py index 47eebd8b..e78fa193 100644 --- a/lib/plexapi/config.py +++ b/lib/plexapi/config.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import os from collections import defaultdict -from plexapi.compat import ConfigParser +from configparser import ConfigParser class PlexConfig(ConfigParser): @@ -13,6 +13,7 @@ class PlexConfig(ConfigParser): Parameters: path (str): Path of the configuration file to load. """ + def __init__(self, path): ConfigParser.__init__(self) self.read(path) diff --git a/lib/plexapi/gdm.py b/lib/plexapi/gdm.py index 84c7acaf..9610bb0d 100644 --- a/lib/plexapi/gdm.py +++ b/lib/plexapi/gdm.py @@ -23,12 +23,12 @@ class GDM: """Scan the network.""" self.update(scan_for_clients) - def all(self): + def all(self, scan_for_clients=False): """Return all found entries. Will scan for entries if not scanned recently. """ - self.scan() + self.scan(scan_for_clients) return list(self.entries) def find_by_content_type(self, value): diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index 306deb3b..e27df987 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- -from plexapi import X_PLEX_CONTAINER_SIZE, log, utils, media -from plexapi.base import PlexObject, PlexPartialObject -from plexapi.compat import quote, quote_plus, unquote, urlencode +from urllib.parse import quote, quote_plus, unquote, urlencode + +from plexapi import X_PLEX_CONTAINER_SIZE, log, media, utils +from plexapi.base import OPERATORS, PlexObject, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound -from plexapi.media import MediaTag from plexapi.settings import Setting +from plexapi.utils import deprecated class Library(PlexObject): @@ -109,7 +110,7 @@ class Library(PlexObject): 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') + self._server.query('/library/clean/bundles?async=1', method=self._server._session.put) def emptyTrash(self): """ If a library has items in the Library Trash, use this option to empty the Trash. """ @@ -121,7 +122,7 @@ class Library(PlexObject): 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') + self._server.query('/library/optimize?async=1', method=self._server._session.put) def update(self): """ Scan this library for new items.""" @@ -166,11 +167,12 @@ class Library(PlexObject): **Movie Preferences** - * **agent** (str): com.plexapp.agents.none, com.plexapp.agents.imdb, com.plexapp.agents.themoviedb + * **agent** (str): com.plexapp.agents.none, com.plexapp.agents.imdb, tv.plex.agents.movie, + 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 + * **scanner** (str): Plex Movie, Plex Movie Scanner, Plex Video Files Scanner, Plex Video Files **IMDB Movie Options** (com.plexapp.agents.imdb) @@ -311,33 +313,23 @@ 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. + agent (str): The metadata agent used for the library section (com.plexapp.agents.imdb, etc). + allowSync (bool): True if you allow syncing content from the library section. + art (str): Background artwork used to respresent the library section. + composite (str): Composite image used to represent the library section. + createdAt (datetime): Datetime the 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. + locations (List): List of folder paths added to the library section. + refreshing (bool): 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) - totalSize (int): Total number of item in the library - + thumb (str): Thumbnail image used to represent the library section. + title (str): Name of the library section. + type (str): Type of content section represents (movie, show, artist, photo). + updatedAt (datetime): Datetime the library section was last updated. + uuid (str): Unique id for the section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63) """ - ALLOWED_FILTERS = () - ALLOWED_SORT = () - BOOLEAN_FILTERS = ('unwatched', 'duplicate') def _loadData(self, data): self._data = data @@ -396,6 +388,7 @@ class LibrarySection(PlexObject): @property def totalSize(self): + """ Returns the total number of items in the library. """ if self._total_size is None: part = '/library/sections/%s/all?X-Plex-Container-Start=0&X-Plex-Container-Size=1' % self.key data = self._server.query(part) @@ -441,18 +434,58 @@ class LibrarySection(PlexObject): key = '/library/sections/%s/all?title=%s' % (self.key, quote(title, safe='')) return self.fetchItem(key, title__iexact=title) - def all(self, sort=None, **kwargs): - """ Returns a list of media from this library section. - - Parameters: - sort (string): The sort string + def all(self, libtype=None, **kwargs): + """ Returns a list of all items from this library section. + See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting. """ - sortStr = '' - if sort is not None: - sortStr = '?sort=' + sort + libtype = libtype or self.TYPE + return self.search(libtype=libtype, **kwargs) - key = '/library/sections/%s/all%s' % (self.key, sortStr) - return self.fetchItems(key, **kwargs) + def folders(self): + """ Returns a list of available :class:`~plexapi.library.Folder` for this library section. + """ + key = '/library/sections/%s/folder' % self.key + return self.fetchItems(key, Folder) + + def hubs(self): + """ Returns a list of available :class:`~plexapi.library.Hub` for this library section. + """ + key = '/hubs/sections/%s' % self.key + return self.fetchItems(key) + + def _filters(self): + """ Returns a list of :class:`~plexapi.library.Filter` from this library section. """ + key = '/library/sections/%s/filters' % self.key + return self.fetchItems(key, cls=Filter) + + def _sorts(self, mediaType=None): + """ Returns a list of available :class:`~plexapi.library.Sort` for this library section. + """ + items = [] + for data in self.listChoices('sorts', mediaType): + sort = Sort(server=self._server, data=data._data) + sort._initpath = data._initpath + items.append(sort) + return items + + def filterFields(self, mediaType=None): + """ Returns a list of available :class:`~plexapi.library.FilterField` for this library section. + """ + items = [] + key = '/library/sections/%s/filters?includeMeta=1' % self.key + data = self._server.query(key) + for meta in data.iter('Meta'): + for metaType in meta.iter('Type'): + if not mediaType or metaType.attrib.get('type') == mediaType: + fields = self.findItems(metaType, FilterField) + for field in fields: + field._initpath = metaType.attrib.get('key') + fieldType = [_ for _ in self.findItems(meta, FieldType) if _.type == field.type] + field.operators = fieldType[0].operators + items += fields + if not items and mediaType: + raise BadRequest('mediaType (%s) not found.' % mediaType) + return items def agents(self): """ Returns a list of available :class:`~plexapi.media.Agent` for this library section. @@ -465,6 +498,49 @@ class LibrarySection(PlexObject): data = self._server.query(key) return self.findItems(data, cls=Setting) + def editAdvanced(self, **kwargs): + """ Edit a library's advanced settings. """ + data = {} + idEnums = {} + key = 'prefs[%s]' + + for setting in self.settings(): + if setting.type != 'bool': + idEnums[setting.id] = setting.enumValues + else: + idEnums[setting.id] = {0: False, 1: True} + + for settingID, value in kwargs.items(): + try: + enums = idEnums.get(settingID) + enumValues = [int(x) for x in enums] + except TypeError: + raise NotFound('%s not found in %s' % (value, list(idEnums.keys()))) + if value in enumValues: + data[key % settingID] = value + else: + raise NotFound('%s not found in %s' % (value, enums)) + + self.edit(**data) + + def defaultAdvanced(self): + """ Edit all of library's advanced settings to default. """ + data = {} + key = 'prefs[%s]' + for setting in self.settings(): + if setting.type == 'bool': + data[key % setting.id] = int(setting.default) + else: + data[key % setting.id] = setting.default + + self.edit(**data) + + def timeline(self): + """ Returns a timeline query for this library section. """ + key = '/library/sections/%s/timeline' % self.key + data = self._server.query(key) + return LibraryTimeline(self, data) + def onDeck(self): """ Returns a list of media items on deck from this library section. """ key = '/library/sections/%s/onDeck' % self.key @@ -478,6 +554,10 @@ class LibrarySection(PlexObject): """ return self.search(sort='addedAt:desc', maxresults=maxresults) + def firstCharacter(self): + key = '/library/sections/%s/firstCharacter' % self.key + return self.fetchItems(key, cls=FirstCharacter) + 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. @@ -490,9 +570,15 @@ class LibrarySection(PlexObject): 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. """ + def update(self, path=None): + """ Scan this section for new media. + + Parameters: + path (str, optional): Full path to folder to scan. + """ key = '/library/sections/%s/refresh' % self.key + if path is not None: + key += '?path=%s' % quote_plus(path) self._server.query(key) def cancelUpdate(self): @@ -526,7 +612,7 @@ class LibrarySection(PlexObject): **kwargs (dict): Additional kwargs to narrow down the choices. Raises: - :exc:`plexapi.exceptions.BadRequest`: Cannot include kwarg equal to specified category. + :exc:`~plexapi.exceptions.BadRequest`: Cannot include kwarg equal to specified category. """ # TODO: Should this be moved to base? if category in kwargs: @@ -573,12 +659,14 @@ class LibrarySection(PlexObject): * year: List of years to search within ([yyyy, ...]). [all] Raises: - :exc:`plexapi.exceptions.BadRequest`: when applying unknown filter + :exc:`~plexapi.exceptions.BadRequest`: When applying an unknown filter. """ # cleanup the core arguments args = {} - for category, value in kwargs.items(): - args[category] = self._cleanSearchFilter(category, value, libtype) + for category, value in list(kwargs.items()): + if category.split('__')[-1] not in OPERATORS: + args[category] = self._cleanSearchFilter(category, value, libtype) + del kwargs[category] if title is not None: args['title'] = title if sort is not None: @@ -595,7 +683,7 @@ class LibrarySection(PlexObject): while True: key = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args)) subresults = self.fetchItems(key, container_start=container_start, - container_size=container_size) + container_size=container_size, **kwargs) if not len(subresults): if offset > self.totalSize: log.info("container_start is higher then the number of items in the library") @@ -620,12 +708,14 @@ class LibrarySection(PlexObject): def _cleanSearchFilter(self, category, value, libtype=None): # check a few things before we begin + categories = [x.key for x in self.filterFields()] + booleanFilters = [x.key for x in self.filterFields() if x.type == 'boolean'] if category.endswith('!'): - if category[:-1] not in self.ALLOWED_FILTERS: + if category[:-1] not in categories: raise BadRequest('Unknown filter category: %s' % category[:-1]) - elif category not in self.ALLOWED_FILTERS: + elif category not in categories: raise BadRequest('Unknown filter category: %s' % category) - if category in self.BOOLEAN_FILTERS: + if category in booleanFilters: return '1' if value else '0' if not isinstance(value, (list, tuple)): value = [value] @@ -635,7 +725,7 @@ class LibrarySection(PlexObject): 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() + item = str((item.id or item.tag) if isinstance(item, media.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 @@ -649,13 +739,19 @@ class LibrarySection(PlexObject): 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} + allowedSort = [sort.key for sort in self._sorts()] + lookup = {s.lower(): s for s in allowedSort} 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) + def _locations(self): + """ Returns a list of :class:`~plexapi.library.Location` objects + """ + return self.findItems(self._data, Location) + def sync(self, policy, mediaSettings, client=None, clientId=None, title=None, sort=None, libtype=None, **kwargs): """ Add current library section as sync item for specified device. @@ -684,7 +780,7 @@ class LibrarySection(PlexObject): :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. Raises: - :exc:`plexapi.exceptions.BadRequest`: when the library is not allowed to sync + :exc:`~plexapi.exceptions.BadRequest`: When the library is not allowed to sync. Example: @@ -740,48 +836,34 @@ class LibrarySection(PlexObject): """ return self._server.history(maxresults=maxresults, mindate=mindate, librarySectionID=self.key, accountID=1) + @deprecated('use "collections" (plural) instead') + def collection(self, **kwargs): + return self.collections() + + def collections(self, **kwargs): + """ Returns a list of collections from this library section. + See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting. + """ + return self.search(libtype='collection', **kwargs) + + def playlists(self, **kwargs): + """ Returns a list of playlists from this library section. """ + key = '/playlists?type=15&playlistType=%s§ionID=%s' % (self.CONTENT_TYPE, self.key) + return self.fetchItems(key, **kwargs) + 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', 'writer', 'producer', 'subtitleLanguage', 'audioLanguage', - 'lastViewedAt', 'viewCount', 'addedAt') - ALLOWED_SORT = ('addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating', - 'mediaHeight', 'duration') TAG = 'Directory' TYPE = 'movie' METADATA_TYPE = 'movie' CONTENT_TYPE = 'video' - def all(self, **kwargs): - """ Returns a list of all items from this library section. - See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting. - """ - return self.search(libtype='movie', **kwargs) - - def collection(self, **kwargs): - """ Returns a list of collections from this library section. - See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting. - """ - return self.search(libtype='collection', **kwargs) - - def playlist(self, **kwargs): - """ Returns a list of playlists from this library section. """ - key = '/playlists?type=15&playlistType=%s§ionID=%s' % (self.CONTENT_TYPE, self.key) - return self.fetchItems(key, **kwargs) - def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): """ Add current Movie library section as sync item for specified device. See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and @@ -822,21 +904,10 @@ 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', 'show.title', 'show.year', 'show.userRating', - 'show.viewCount', 'show.lastViewedAt', 'show.actor', 'show.addedAt', 'episode.title', - 'episode.originallyAvailableAt', 'episode.resolution', 'episode.subtitleLanguage', - 'episode.unwatched', 'episode.addedAt', 'episode.userRating', 'episode.viewCount', - 'episode.lastViewedAt') - ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort', - 'rating', 'unwatched') + TAG = 'Directory' TYPE = 'show' METADATA_TYPE = 'episode' @@ -856,24 +927,7 @@ class ShowSection(LibrarySection): Parameters: maxresults (int): Max number of items to return (default 50). """ - return self.search(sort='addedAt:desc', libtype=libtype, maxresults=maxresults) - - def all(self, libtype='show', **kwargs): - """ Returns a list of all items from this library section. - See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting. - """ - return self.search(libtype=libtype, **kwargs) - - def collection(self, **kwargs): - """ Returns a list of collections from this library section. - See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting. - """ - return self.search(libtype='collection', **kwargs) - - def playlist(self, **kwargs): - """ Returns a list of playlists from this library section. """ - key = '/playlists?type=15&playlistType=%s§ionID=%s' % (self.CONTENT_TYPE, self.key) - return self.fetchItems(key, **kwargs) + return self.search(sort='episode.addedAt:desc', libtype=libtype, maxresults=maxresults) def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): """ Add current Show library section as sync item for specified device. @@ -915,20 +969,9 @@ 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', 'year', 'track.userRating', 'artist.title', - 'artist.userRating', 'artist.genre', 'artist.country', 'artist.collection', 'artist.addedAt', - 'album.title', 'album.userRating', 'album.genre', 'album.decade', 'album.collection', - 'album.viewCount', 'album.lastViewedAt', 'album.studio', 'album.addedAt', 'track.title', - 'track.userRating', 'track.viewCount', 'track.lastViewedAt', 'track.skipCount', - 'track.lastSkippedAt') - ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort', 'userRating') TAG = 'Directory' TYPE = 'artist' @@ -940,6 +983,11 @@ class MusicSection(LibrarySection): key = '/library/sections/%s/albums' % self.key return self.fetchItems(key) + def stations(self): + """ Returns a list of :class:`~plexapi.audio.Album` objects in this section. """ + key = '/hubs/sections/%s?includeStations=1' % self.key + return self.fetchItems(key, cls=Station) + def searchArtists(self, **kwargs): """ Search for an artist. See :func:`~plexapi.library.LibrarySection.search` for usage. """ return self.search(libtype='artist', **kwargs) @@ -952,23 +1000,6 @@ class MusicSection(LibrarySection): """ Search for a track. See :func:`~plexapi.library.LibrarySection.search` for usage. """ return self.search(libtype='track', **kwargs) - def all(self, libtype='artist', **kwargs): - """ Returns a list of all items from this library section. - See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting. - """ - return self.search(libtype=libtype, **kwargs) - - def collection(self, **kwargs): - """ Returns a list of collections from this library section. - See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting. - """ - return self.search(libtype='collection', **kwargs) - - def playlist(self, **kwargs): - """ Returns a list of playlists from this library section. """ - key = '/playlists?type=15&playlistType=%s§ionID=%s' % (self.CONTENT_TYPE, self.key) - return self.fetchItems(key, **kwargs) - def sync(self, bitrate, limit=None, **kwargs): """ Add current Music library section as sync item for specified device. See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and @@ -1008,20 +1039,24 @@ 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', 'device', 'resolution') - ALLOWED_SORT (list): List of allowed sorting keys. ('addedAt') TAG (str): 'Directory' TYPE (str): 'photo' """ - ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure', 'device', 'resolution', 'place', - 'originallyAvailableAt', 'addedAt', 'title', 'userRating', 'tag', 'year') - ALLOWED_SORT = ('addedAt',) TAG = 'Directory' TYPE = 'photo' CONTENT_TYPE = 'photo' METADATA_TYPE = 'photo' + def all(self, libtype=None, **kwargs): + """ Returns a list of all items from this library section. + See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting. + """ + libtype = libtype or 'photoalbum' + return self.search(libtype=libtype, **kwargs) + + def collections(self, **kwargs): + raise NotImplementedError('Collections are not available for a Photo library.') + def searchAlbums(self, title, **kwargs): """ Search for an album. See :func:`~plexapi.library.LibrarySection.search` for usage. """ return self.search(libtype='photoalbum', title=title, **kwargs) @@ -1030,17 +1065,6 @@ class PhotoSection(LibrarySection): """ Search for a photo. See :func:`~plexapi.library.LibrarySection.search` for usage. """ return self.search(libtype='photo', title=title, **kwargs) - def all(self, libtype='photoalbum', **kwargs): - """ Returns a list of all items from this library section. - See description of :func:`plexapi.library.LibrarySection.search()` for details about filtering / sorting. - """ - return self.search(libtype=libtype, **kwargs) - - def playlist(self, **kwargs): - """ Returns a list of playlists from this library section. """ - key = '/playlists?type=15&playlistType=%s§ionID=%s' % (self.CONTENT_TYPE, self.key) - return self.fetchItems(key, **kwargs) - def sync(self, resolution, limit=None, **kwargs): """ Add current Music library section as sync item for specified device. See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and @@ -1104,19 +1128,247 @@ class FilterChoice(PlexObject): self.type = data.attrib.get('type') +@utils.registerPlexObject +class LibraryTimeline(PlexObject): + """Represents a LibrarySection timeline. + + Attributes: + TAG (str): 'LibraryTimeline' + size (int): Unknown + allowSync (bool): Unknown + art (str): Relative path to art image. + content (str): "secondary" + identifier (str): "com.plexapp.plugins.library" + latestEntryTime (int): Epoch timestamp + mediaTagPrefix (str): "/system/bundle/media/flags/" + mediaTagVersion (int): Unknown + thumb (str): Relative path to library thumb image. + title1 (str): Name of library section. + updateQueueSize (int): Number of items queued to update. + viewGroup (str): "secondary" + viewMode (int): Unknown + """ + TAG = 'LibraryTimeline' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.size = utils.cast(int, data.attrib.get('size')) + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.art = data.attrib.get('art') + self.content = data.attrib.get('content') + self.identifier = data.attrib.get('identifier') + self.latestEntryTime = utils.cast(int, data.attrib.get('latestEntryTime')) + self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') + self.mediaTagVersion = utils.cast(int, data.attrib.get('mediaTagVersion')) + self.thumb = data.attrib.get('thumb') + self.title1 = data.attrib.get('title1') + self.updateQueueSize = utils.cast(int, data.attrib.get('updateQueueSize')) + self.viewGroup = data.attrib.get('viewGroup') + self.viewMode = utils.cast(int, data.attrib.get('viewMode')) + + +@utils.registerPlexObject +class Location(PlexObject): + """ Represents a single library Location. + + Attributes: + TAG (str): 'Location' + id (int): Location path ID. + path (str): Path used for library.. + """ + TAG = 'Location' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.id = utils.cast(int, data.attrib.get('id')) + self.path = data.attrib.get('path') + + +class Filter(PlexObject): + """ Represents a single Filter. + + Attributes: + TAG (str): 'Directory' + TYPE (str): 'filter' + """ + TAG = 'Directory' + TYPE = 'filter' + + def _loadData(self, data): + self._data = data + self.filter = data.attrib.get('filter') + self.filterType = data.attrib.get('filterType') + self.key = data.attrib.get('key') + 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' + context (str): The context of the hub. + hubKey (str): API URL for these specific hub items. + hubIdentifier (str): The identifier of the hub. + key (str): API URL for the hub. + more (bool): True if there are more items to load (call reload() to fetch all items). + size (int): The number of items in the hub. + style (str): The style of the hub. + title (str): The title of the hub. + type (str): The type of items in the hub. + """ + TAG = 'Hub' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.context = data.attrib.get('context') + self.hubKey = data.attrib.get('hubKey') + self.hubIdentifier = data.attrib.get('hubIdentifier') + self.items = self.findItems(data) + self.key = data.attrib.get('key') + self.more = utils.cast(bool, data.attrib.get('more')) + self.size = utils.cast(int, data.attrib.get('size')) + self.style = data.attrib.get('style') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + + def __len__(self): + return self.size + + def reload(self): + """ Reloads the hub to fetch all items in the hub. """ + if self.more and self.key: + self.items = self.fetchItems(self.key) + self.more = False + self.size = len(self.items) + + +class HubMediaTag(PlexObject): + """ Base class of hub media tag search results. + + Attributes: + count (int): The number of items where this tag is found. + filter (str): The URL filter for the tag. + id (int): The id of the tag. + key (str): API URL (/library/section//all?). + librarySectionID (int): The library section ID where the tag is found. + librarySectionKey (str): API URL for the library section (/library/section/) + librarySectionTitle (str): The library title where the tag is found. + librarySectionType (int): The library type where the tag is found. + reason (str): The reason for the search result. + reasonID (int): The reason ID for the search result. + reasonTitle (str): The reason title for the search result. + type (str): The type of search result (tag). + tag (str): The title of the tag. + tagType (int): The type ID of the tag. + tagValue (int): The value of the tag. + thumb (str): The URL for the thumbnail of the tag (if available). + """ + TAG = 'Directory' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.count = utils.cast(int, data.attrib.get('count')) + self.filter = data.attrib.get('filter') + self.id = utils.cast(int, data.attrib.get('id')) + self.key = data.attrib.get('key') + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.librarySectionType = utils.cast(int, data.attrib.get('librarySectionType')) + self.reason = data.attrib.get('reason') + self.reasonID = utils.cast(int, data.attrib.get('reasonID')) + self.reasonTitle = data.attrib.get('reasonTitle') + self.type = data.attrib.get('type') + self.tag = data.attrib.get('tag') + self.tagType = utils.cast(int, data.attrib.get('tagType')) + self.tagValue = utils.cast(int, data.attrib.get('tagValue')) + self.thumb = data.attrib.get('thumb') + + +@utils.registerPlexObject +class Tag(HubMediaTag): + """ Represents a single Tag hub search media tag. + + Attributes: + TAGTYPE (int): 0 + """ + TAGTYPE = 0 + + +@utils.registerPlexObject +class Genre(HubMediaTag): + """ Represents a single Genre hub search media tag. + + Attributes: + TAGTYPE (int): 1 + """ + TAGTYPE = 1 + + +@utils.registerPlexObject +class Director(HubMediaTag): + """ Represents a single Director hub search media tag. + + Attributes: + TAGTYPE (int): 4 + """ + TAGTYPE = 4 + + +@utils.registerPlexObject +class Actor(HubMediaTag): + """ Represents a single Actor hub search media tag. + + Attributes: + TAGTYPE (int): 6 + """ + TAGTYPE = 6 + + +@utils.registerPlexObject +class AutoTag(HubMediaTag): + """ Represents a single AutoTag hub search media tag. + + Attributes: + TAGTYPE (int): 207 + """ + TAGTYPE = 207 + + +@utils.registerPlexObject +class Place(HubMediaTag): + """ Represents a single Place hub search media tag. + + Attributes: + TAGTYPE (int): 400 + """ + TAGTYPE = 400 + + +@utils.registerPlexObject +class Station(PlexObject): + """ Represents the Station area in the MusicSection. + + Attributes: + TITLE (str): 'Stations' + TYPE (str): 'station' hubIdentifier (str): Unknown. size (int): Number of items found. title (str): Title of this Hub. type (str): Type of items in the Hub. + more (str): Unknown. + style (str): Unknown items (str): List of items in the Hub. """ - TAG = 'Hub' + TITLE = 'Stations' + TYPE = 'station' def _loadData(self, data): """ Load attribute values from Plex XML response. """ @@ -1125,108 +1377,269 @@ class Hub(PlexObject): self.size = utils.cast(int, data.attrib.get('size')) self.title = data.attrib.get('title') self.type = data.attrib.get('type') - self.key = data.attrib.get('key') + self.more = data.attrib.get('more') + self.style = data.attrib.get('style') self.items = self.findItems(data) def __len__(self): return self.size +class Sort(PlexObject): + """ Represents a Sort element found in library. + + Attributes: + TAG (str): 'Sort' + defaultDirection (str): Default sorting direction. + descKey (str): Url key for sorting with desc. + key (str): Url key for sorting, + title (str): Title of sorting, + firstCharacterKey (str): Url path for first character endpoint. + """ + TAG = 'Sort' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.defaultDirection = data.attrib.get('defaultDirection') + self.descKey = data.attrib.get('descKey') + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + self.firstCharacterKey = data.attrib.get('firstCharacterKey') + + +class FilterField(PlexObject): + """ Represents a Filters Field element found in library. + + Attributes: + TAG (str): 'Field' + key (str): Url key for filter, + title (str): Title of filter. + type (str): Type of filter (string, boolean, integer, date, etc). + subType (str): Subtype of filter (decade, rating, etc). + operators (str): Operators available for this filter. + """ + TAG = 'Field' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.subType = data.attrib.get('subType') + self.operators = [] + + +@utils.registerPlexObject +class Operator(PlexObject): + """ Represents an Operator available for filter. + + Attributes: + TAG (str): 'Operator' + key (str): Url key for operator. + title (str): Title of operator. + """ + TAG = 'Operator' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + + +class Folder(PlexObject): + """ Represents a Folder inside a library. + + Attributes: + key (str): Url key for folder. + title (str): Title of folder. + """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self.key = data.attrib.get('key') + self.title = data.attrib.get('title') + + def subfolders(self): + """ Returns a list of available :class:`~plexapi.library.Folder` for this folder. + Continue down subfolders until a mediaType is found. + """ + if self.key.startswith('/library/metadata'): + return self.fetchItems(self.key) + else: + return self.fetchItems(self.key, Folder) + + def allSubfolders(self): + """ Returns a list of all available :class:`~plexapi.library.Folder` for this folder. + Only returns :class:`~plexapi.library.Folder`. + """ + folders = [] + for folder in self.subfolders(): + if not folder.key.startswith('/library/metadata'): + folders.append(folder) + while True: + for subfolder in folder.subfolders(): + if not subfolder.key.startswith('/library/metadata'): + folders.append(subfolder) + continue + break + return folders + + +@utils.registerPlexObject +class FieldType(PlexObject): + """ Represents a FieldType for filter. + + Attributes: + TAG (str): 'Operator' + type (str): Type of filter (string, boolean, integer, date, etc), + operators (str): Operators available for this filter. + """ + TAG = 'FieldType' + + def __repr__(self): + _type = self._clean(self.firstAttr('type')) + return '<%s>' % ':'.join([p for p in [self.__class__.__name__, _type] if p]) + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.type = data.attrib.get('type') + self.operators = self.findItems(data, Operator) + + +class FirstCharacter(PlexObject): + """ Represents a First Character element from a library. + + Attributes: + key (str): Url key for character. + size (str): Total amount of library items starting with this character. + title (str): Character (#, !, A, B, C, ...). + """ + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.key = data.attrib.get('key') + self.size = data.attrib.get('size') + self.title = data.attrib.get('title') + + @utils.registerPlexObject class Collections(PlexPartialObject): """ Represents a single Collection. + Attributes: TAG (str): 'Directory' TYPE (str): 'collection' - ratingKey (int): Unique key identifying this item. - addedAt (datetime): Datetime this item was added to the library. - art (str): URL to artwork image. + addedAt (datetime): Datetime the collection was added to the library. + art (str): URL to artwork image (/library/metadata//art/). artBlurHash (str): BlurHash string for artwork image. - childCount (int): Count of child object(s) + childCount (int): Number of items in the collection. collectionMode (str): How the items in the collection are displayed. collectionSort (str): How to sort the items in the collection. contentRating (str) Content rating (PG-13; NR; TV-G). - fields (list): List of :class:`~plexapi.media.Field`. - guid (str): Plex GUID (collection://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX). - index (int): Unknown + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the collection (collection://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX). + index (int): Plex index number for the collection. key (str): API URL (/library/metadata/). - labels (List<:class:`~plexapi.media.Label`>): List of field objects. + labels (List<:class:`~plexapi.media.Label`>): List of label objects. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. - librarySectionKey (str): API URL (/library/sections/). - librarySectionTitle (str): Section Title - maxYear (int): Year - minYear (int): Year - subtype (str): Media type - summary (str): Summary of the collection - thumb (str): URL to thumbnail image. + librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + maxYear (int): Maximum year for the items in the collection. + minYear (int): Minimum year for the items in the collection. + ratingKey (int): Unique key identifying the collection. + subtype (str): Media type of the items in the collection (movie, show, artist, or album). + summary (str): Summary of the collection. + thumb (str): URL to thumbnail image (/library/metadata//thumb/). thumbBlurHash (str): BlurHash string for thumbnail image. - title (str): Collection Title + title (str): Name of the collection. titleSort (str): Title to use when sorting (defaults to title). - type (str): Hardcoded 'collection' - updatedAt (datatime): Datetime this item was updated. + type (str): 'collection' + updatedAt (datatime): Datetime the collection was updated. """ TAG = 'Directory' TYPE = 'collection' def _loadData(self, data): - self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) - self.key = data.attrib.get('key').replace('/children', '') # FIX_BUG_50 self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') self.childCount = utils.cast(int, data.attrib.get('childCount')) - self.collectionMode = utils.cast(int, data.attrib.get('collectionMode')) - self.collectionSort = utils.cast(int, data.attrib.get('collectionSort')) + self.collectionMode = data.attrib.get('collectionMode') + self.collectionSort = data.attrib.get('collectionSort') self.contentRating = data.attrib.get('contentRating') self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') self.index = utils.cast(int, data.attrib.get('index')) + self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 self.labels = self.findItems(data, media.Label) self.librarySectionID = data.attrib.get('librarySectionID') self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.maxYear = utils.cast(int, data.attrib.get('maxYear')) self.minYear = utils.cast(int, data.attrib.get('minYear')) + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.subtype = data.attrib.get('subtype') self.summary = data.attrib.get('summary') self.thumb = data.attrib.get('thumb') self.thumbBlurHash = data.attrib.get('thumbBlurHash') self.title = data.attrib.get('title') - self.titleSort = data.attrib.get('titleSort') + self.titleSort = data.attrib.get('titleSort', self.title) self.type = data.attrib.get('type') self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) + @property + @deprecated('use "items" instead') def children(self): + return self.fetchItems(self.key) + + def item(self, title): + """ Returns the item in the collection that matches the specified title. + + Parameters: + title (str): Title of the item to return. + """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItem(key, title__iexact=title) + + def items(self): """ Returns a list of all items in the collection. """ key = '/library/metadata/%s/children' % self.ratingKey return self.fetchItems(key) - @property - def thumbUrl(self): - """ Return the thumbnail url for the collection.""" - return self._server.url(self.thumb, includeToken=True) if self.thumb else None - - @property - def artUrl(self): - """ Return the art url for the collection.""" - return self._server.url(self.art, includeToken=True) if self.art else None + def get(self, title): + """ Alias to :func:`~plexapi.library.Collection.item`. """ + return self.item(title) def __len__(self): return self.childCount + def _preferences(self): + """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ + items = [] + data = self._server.query(self._details_key) + for item in data.iter('Setting'): + items.append(Setting(data=item, server=self._server)) + + return items + def delete(self): part = '/library/metadata/%s' % self.ratingKey return self._server.query(part, method=self._server._session.delete) def modeUpdate(self, mode=None): """ Update Collection Mode + Parameters: mode: default (Library default) hide (Hide Collection) hideItems (Hide Items in this Collection) showItems (Show this Collection and its Items) Example: + collection = 'plexapi.library.Collections' collection.updateMode(mode="hide") """ @@ -1242,10 +1655,13 @@ class Collections(PlexPartialObject): def sortUpdate(self, sort=None): """ Update Collection Sorting + Parameters: sort: realease (Order Collection by realease dates) alpha (Order Collection Alphabetically) + Example: + colleciton = 'plexapi.library.Collections' collection.updateSort(mode="alpha") """ diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 27f114bd..00007896 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -1,41 +1,50 @@ # -*- coding: utf-8 -*- import xml +from urllib.parse import quote_plus -from plexapi import compat, log, settings, utils +from plexapi import log, settings, utils from plexapi.base import PlexObject from plexapi.exceptions import BadRequest -from plexapi.utils import cast, SEARCHTYPES +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. + video or audio 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 (?). + aspectRatio (float): The aspect ratio of the media (ex: 2.35). + audioChannels (int): The number of audio channels of the media (ex: 6). + audioCodec (str): The audio codec of the media (ex: ac3). + audioProfile (str): The audio profile of the media (ex: dts). + bitrate (int): The bitrate of the media (ex: 1624). + container (str): The container of the media (ex: avi). + duration (int): The duration of the media in milliseconds (ex: 6990483). + height (int): The height of the media in pixels (ex: 256). + id (int): The unique ID for this media on the server. + has64bitOffsets (bool): True if video has 64 bit offsets. optimizedForStreaming (bool): True if video is optimized for streaming. - target (str): Media version target name. - title (str): Media version title. - videoCodec (str): Video codec used within the video (ex: ac3). - videoFrameRate (str): Video frame rate (ex: 24p). - videoResolution (str): Video resolution (ex: sd). - videoProfile (str): Video profile (ex: high). - width (int): Width of the video in pixels (ex: 608). - parts (list<:class:`~plexapi.media.MediaPart`>): List of MediaParts in this video. + parts (List<:class:`~plexapi.media.MediaPart`>): List of media part objects. + proxyType (int): Equals 42 for optimized versions. + target (str): The media version target name. + title (str): The title of the media. + videoCodec (str): The video codec of the media (ex: ac3). + videoFrameRate (str): The video frame rate of the media (ex: 24p). + videoProfile (str): The video profile of the media (ex: high). + videoResolution (str): The video resolution of the media (ex: sd). + width (int): The width of the video in pixels (ex: 608). + + : The following attributes are only available for photos. + + * aperture (str): The apeture used to take the photo. + * exposure (str): The exposure used to take the photo. + * iso (int): The iso used to take the photo. + * lens (str): The lens used to take the photo. + * make (str): The make of the camera used to take the photo. + * model (str): The model of the camera used to take the photo. """ TAG = 'Media' @@ -53,6 +62,8 @@ class Media(PlexObject): 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.parts = self.findItems(data, MediaPart) + self.proxyType = cast(int, data.attrib.get('proxyType')) self.target = data.attrib.get('target') self.title = data.attrib.get('title') self.videoCodec = data.attrib.get('videoCodec') @@ -60,17 +71,19 @@ class Media(PlexObject): self.videoProfile = data.attrib.get('videoProfile') self.videoResolution = data.attrib.get('videoResolution') self.width = cast(int, data.attrib.get('width')) - self.parts = self.findItems(data, MediaPart) - self.proxyType = cast(int, data.attrib.get('proxyType')) - self.optimizedVersion = self.proxyType == SEARCHTYPES['optimizedVersion'] - - # For Photo only - self.aperture = data.attrib.get('aperture') - self.exposure = data.attrib.get('exposure') - self.iso = cast(int, data.attrib.get('iso')) - self.lens = data.attrib.get('lens') - self.make = data.attrib.get('make') - self.model = data.attrib.get('model') + + if self._isChildOf(etag='Photo'): + self.aperture = data.attrib.get('aperture') + self.exposure = data.attrib.get('exposure') + self.iso = cast(int, data.attrib.get('iso')) + self.lens = data.attrib.get('lens') + self.make = data.attrib.get('make') + self.model = data.attrib.get('model') + + @property + def isOptimizedVersion(self): + """ Returns True if the media is a Plex optimized version. """ + return self.proxyType == utils.SEARCHTYPES['optimizedVersion'] def delete(self): part = self._initpath + '/media/%s' % self.id @@ -88,73 +101,77 @@ class MediaPart(PlexObject): 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. - exists (bool): Determine if file exists - accessible (bool): Determine if file is accessible + accessible (bool): True if the file is accessible. + audioProfile (str): The audio profile of the file. + container (str): The container type of the file (ex: avi). + decision (str): Unknown. + deepAnalysisVersion (int): The Plex deep analysis version for the file. + duration (int): The duration of the file in milliseconds. + exists (bool): True if the file exists. + file (str): The path to this file on disk (ex: /media/Movies/Cars (2006)/Cars (2006).mkv) + has64bitOffsets (bool): True if the file has 64 bit offsets. + hasThumbnail (bool): True if the file (track) has an embedded thumbnail. + id (int): The unique ID for this media part on the server. + indexes (str, None): sd if the file has generated BIF thumbnails. + key (str): API URL (ex: /library/parts/46618/1389985872/file.mkv). + optimizedForStreaming (bool): True if the file is optimized for streaming. + packetLength (int): The packet length of the file. + requiredBandwidths (str): The required bandwidths to stream the file. + size (int): The size of the file in bytes (ex: 733884416). + streams (List<:class:`~plexapi.media.MediaPartStream`>): List of stream objects. + syncItemId (int): The unique ID for this media part if it is synced. + syncState (str): The sync state for this media part. + videoProfile (str): The video profile of the file. """ TAG = 'Part' def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data + self.accessible = cast(bool, data.attrib.get('accessible')) self.audioProfile = data.attrib.get('audioProfile') self.container = data.attrib.get('container') + self.decision = data.attrib.get('decision') self.deepAnalysisVersion = cast(int, data.attrib.get('deepAnalysisVersion')) self.duration = cast(int, data.attrib.get('duration')) + self.exists = cast(bool, data.attrib.get('exists')) self.file = data.attrib.get('file') self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets')) self.hasThumbnail = cast(bool, data.attrib.get('hasThumbnail')) 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.decision = data.attrib.get('decision') self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming')) self.packetLength = cast(int, data.attrib.get('packetLength')) self.requiredBandwidths = data.attrib.get('requiredBandwidths') + self.size = cast(int, data.attrib.get('size')) + self.streams = self._buildStreams(data) self.syncItemId = cast(int, data.attrib.get('syncItemId')) self.syncState = data.attrib.get('syncState') self.videoProfile = data.attrib.get('videoProfile') - self.streams = self._buildStreams(data) - self.exists = cast(bool, data.attrib.get('exists')) - self.accessible = cast(bool, data.attrib.get('accessible')) - - # For Photo only - self.orientation = cast(int, data.attrib.get('orientation')) def _buildStreams(self, data): streams = [] - for elem in data: - for cls in (VideoStream, AudioStream, SubtitleStream, LyricStream): - if elem.attrib.get('streamType') == str(cls.STREAMTYPE): - streams.append(cls(self._server, elem, self._initpath)) + for cls in (VideoStream, AudioStream, SubtitleStream, LyricStream): + items = self.findItems(data, cls, streamType=cls.STREAMTYPE) + streams.extend(items) 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] + return [stream for stream in self.streams if isinstance(stream, VideoStream)] 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] + return [stream for stream in self.streams if isinstance(stream, AudioStream)] 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] + return [stream for stream in self.streams if isinstance(stream, SubtitleStream)] def lyricStreams(self): - """ Returns a list of :class:`~plexapi.media.LyricStream` objects in this MediaPart. """ - return [stream for stream in self.streams if stream.streamType == LyricStream.STREAMTYPE] + """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects in this MediaPart. """ + return [stream for stream in self.streams if isinstance(stream, LyricStream)] def setDefaultAudioStream(self, stream): """ Set the default :class:`~plexapi.media.AudioStream` for this MediaPart. @@ -187,73 +204,87 @@ class MediaPart(PlexObject): class MediaPartStream(PlexObject): - """ Base class for media streams. These consist of video, audio and subtitles. + """ Base class for media streams. These consist of video, audio, subtitles, and lyrics. 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). + bitrate (int): The bitrate of the stream. + codec (str): The codec of the stream (ex: srt, ac3, mpeg4). + default (bool): True if this is the default stream. + displayTitle (str): The display title of the stream. + extendedDisplayTitle (str): The extended display title of the stream. + key (str): API URL (/library/streams/) + id (int): The unique ID for this stream on the server. + index (int): The index of the stream. + language (str): The language of the stream (ex: English, ไทย). + languageCode (str): The Ascii language code of the stream (ex: eng, tha). + requiredBandwidths (str): The required bandwidths to stream the file. 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`, - 4=:class:`~plexapi.media.LyricStream`). + streamType (int): The stream type (1= :class:`~plexapi.media.VideoStream`, + 2= :class:`~plexapi.media.AudioStream`, 3= :class:`~plexapi.media.SubtitleStream`). + title (str): The title of the stream. type (int): Alias for streamType. """ def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data + self.bitrate = cast(int, data.attrib.get('bitrate')) self.codec = data.attrib.get('codec') - self.default = cast(bool, data.attrib.get('selected', '0')) + self.default = cast(bool, data.attrib.get('default')) self.displayTitle = data.attrib.get('displayTitle') self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle') + self.key = data.attrib.get('key') 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.requiredBandwidths = data.attrib.get('requiredBandwidths') self.selected = cast(bool, data.attrib.get('selected', '0')) self.streamType = cast(int, data.attrib.get('streamType')) self.title = data.attrib.get('title') 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, 4: LyricStream} - 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`. + """ Represents 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 + anamorphic (str): If the video is anamorphic. + bitDepth (int): The bit depth of the video stream (ex: 8). + cabac (int): The context-adaptive binary arithmetic coding. + chromaLocation (str): The chroma location of the video stream. + chromaSubsampling (str): The chroma subsampling of the video stream (ex: 4:2:0). + codecID (str): The codec ID (ex: XVID). + codedHeight (int): The coded height of the video stream in pixels. + codedWidth (int): The coded width of the video stream in pixels. + colorPrimaries (str): The color primaries of the video stream. + colorRange (str): The color range of the video stream. + colorSpace (str): The color space of the video stream (ex: bt2020). + colorTrc (str): The color trc of the video stream. + DOVIBLCompatID (int): Dolby Vision base layer compatibility ID. + DOVIBLPresent (bool): True if Dolby Vision base layer is present. + DOVIELPresent (bool): True if Dolby Vision enhancement layer is present. + DOVILevel (int): Dolby Vision level. + DOVIPresent (bool): True if Dolby Vision is present. + DOVIProfile (int): Dolby Vision profile. + DOVIRPUPresent (bool): True if Dolby Vision reference processing unit is present. + DOVIVersion (float): The Dolby Vision version. + duration (int): The duration of video stream in milliseconds. + frameRate (float): The frame rate of the video stream (ex: 23.976). + frameRateMode (str): The frame rate mode of the video stream. 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. + height (int): The hight of the video stream in pixels (ex: 1080). + level (int): The codec encoding level of the video stream (ex: 41). + profile (str): The profile of the video stream (ex: asp). + pixelAspectRatio (str): The pixel aspect ratio of the video stream. + pixelFormat (str): The pixel format of the video stream. + refFrames (int): The number of reference frames of the video stream. + scanType (str): The scan type of the video stream (ex: progressive). + streamIdentifier(int): The stream identifier of the video stream. + width (int): The width of the video stream in pixels (ex: 1920). """ TAG = 'Stream' STREAMTYPE = 1 @@ -263,13 +294,12 @@ class VideoStream(MediaPartStream): super(VideoStream, self)._loadData(data) self.anamorphic = data.attrib.get('anamorphic') 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.chromaLocation = data.attrib.get('chromaLocation') self.chromaSubsampling = data.attrib.get('chromaSubsampling') self.codecID = data.attrib.get('codecID') - self.codedHeight = data.attrib.get('codedHeight') - self.codedWidth = data.attrib.get('codedWidth') + self.codedHeight = cast(int, data.attrib.get('codedHeight')) + self.codedWidth = cast(int, data.attrib.get('codedWidth')) self.colorPrimaries = data.attrib.get('colorPrimaries') self.colorRange = data.attrib.get('colorRange') self.colorSpace = data.attrib.get('colorSpace') @@ -285,14 +315,13 @@ class VideoStream(MediaPartStream): self.duration = cast(int, data.attrib.get('duration')) self.frameRate = cast(float, data.attrib.get('frameRate')) self.frameRateMode = data.attrib.get('frameRateMode') - self.hasScalingMatrix = cast(bool, data.attrib.get('hasScalingMatrix')) + 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.requiredBandwidths = data.attrib.get('requiredBandwidths') self.pixelAspectRatio = data.attrib.get('pixelAspectRatio') self.pixelFormat = data.attrib.get('pixelFormat') + self.refFrames = cast(int, data.attrib.get('refFrames')) self.scanType = data.attrib.get('scanType') self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier')) self.width = cast(int, data.attrib.get('width')) @@ -300,20 +329,31 @@ class VideoStream(MediaPartStream): @utils.registerPlexObject class AudioStream(MediaPartStream): - """ Respresents a audio stream within a :class:`~plexapi.media.MediaPart`. + """ Represents 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. + audioChannelLayout (str): The audio channel layout of the audio stream (ex: 5.1(side)). + bitDepth (int): The bit depth of the audio stream (ex: 16). + bitrateMode (str): The bitrate mode of the audio stream (ex: cbr). + channels (int): The number of audio channels of the audio stream (ex: 6). + duration (int): The duration of audio stream in milliseconds. + profile (str): The profile of the audio stream. + samplingRate (int): The sampling rate of the audio stream (ex: xxx) + streamIdentifier (int): The stream identifier of the audio stream. + + : The following attributes are only available for tracks. + + * albumGain (float): The gain for the album. + * albumPeak (float): The peak for the album. + * albumRange (float): The range for the album. + * endRamp (str): The end ramp for the track. + * gain (float): The gain for the track. + * loudness (float): The loudness for the track. + * lra (float): The lra for the track. + * peak (float): The peak for the track. + * startRamp (str): The start ramp for the track. """ TAG = 'Stream' STREAMTYPE = 2 @@ -323,38 +363,37 @@ class AudioStream(MediaPartStream): 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.duration = cast(int, data.attrib.get('duration')) self.profile = data.attrib.get('profile') - self.requiredBandwidths = data.attrib.get('requiredBandwidths') self.samplingRate = cast(int, data.attrib.get('samplingRate')) self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier')) - # For Track only - self.albumGain = cast(float, data.attrib.get('albumGain')) - self.albumPeak = cast(float, data.attrib.get('albumPeak')) - self.albumRange = cast(float, data.attrib.get('albumRange')) - self.endRamp = data.attrib.get('endRamp') - self.gain = cast(float, data.attrib.get('gain')) - self.loudness = cast(float, data.attrib.get('loudness')) - self.lra = cast(float, data.attrib.get('lra')) - self.peak = cast(float, data.attrib.get('peak')) - self.startRamp = data.attrib.get('startRamp') + if self._isChildOf(etag='Track'): + self.albumGain = cast(float, data.attrib.get('albumGain')) + self.albumPeak = cast(float, data.attrib.get('albumPeak')) + self.albumRange = cast(float, data.attrib.get('albumRange')) + self.endRamp = data.attrib.get('endRamp') + self.gain = cast(float, data.attrib.get('gain')) + self.loudness = cast(float, data.attrib.get('loudness')) + self.lra = cast(float, data.attrib.get('lra')) + self.peak = cast(float, data.attrib.get('peak')) + self.startRamp = data.attrib.get('startRamp') @utils.registerPlexObject class SubtitleStream(MediaPartStream): - """ Respresents a audio stream within a :class:`~plexapi.media.MediaPart`. + """ Represents a audio stream within a :class:`~plexapi.media.MediaPart`. Attributes: TAG (str): 'Stream' STREAMTYPE (int): 3 - forced (bool): True if this is a forced subtitle - format (str): Subtitle format (ex: srt). - key (str): Key of this subtitle stream (ex: /library/streams/212284). - title (str): Title of this subtitle stream. + container (str): The container of the subtitle stream. + forced (bool): True if this is a forced subtitle. + format (str): The format of the subtitle stream (ex: srt). + headerCommpression (str): The header compression of the subtitle stream. + transient (str): Unknown. """ TAG = 'Stream' STREAMTYPE = 3 @@ -366,21 +405,19 @@ class SubtitleStream(MediaPartStream): self.forced = cast(bool, data.attrib.get('forced', '0')) self.format = data.attrib.get('format') self.headerCompression = data.attrib.get('headerCompression') - self.key = data.attrib.get('key') - self.requiredBandwidths = data.attrib.get('requiredBandwidths') self.transient = data.attrib.get('transient') -@utils.registerPlexObject class LyricStream(MediaPartStream): - """ Respresents a lyric stream within a :class:`~plexapi.media.MediaPart`. + """ Represents a lyric stream within a :class:`~plexapi.media.MediaPart`. Attributes: TAG (str): 'Stream' STREAMTYPE (int): 4 - format (str): Lyric format (ex: lrc). - key (str): Key of this subtitle stream (ex: /library/streams/212284). - title (str): Title of this lyric stream. + format (str): The format of the lyric stream (ex: lrc). + minLines (int): The minimum number of lines in the (timed) lyric stream. + provider (str): The provider of the lyric stream (ex: com.plexapp.agents.lyricfind). + timed (bool): True if the lyrics are timed to the track. """ TAG = 'Stream' STREAMTYPE = 4 @@ -389,7 +426,6 @@ class LyricStream(MediaPartStream): """ Load attribute values from Plex XML response. """ super(LyricStream, self)._loadData(data) self.format = data.attrib.get('format') - self.key = data.attrib.get('key') self.minLines = cast(int, data.attrib.get('minLines')) self.provider = data.attrib.get('provider') self.timed = cast(bool, data.attrib.get('timed', '0')) @@ -397,7 +433,14 @@ class LyricStream(MediaPartStream): @utils.registerPlexObject class Session(PlexObject): - """ Represents a current session. """ + """ Represents a current session. + + Attributes: + TAG (str): 'Session' + id (str): The unique identifier for the session. + bandwidth (int): The Plex streaming brain reserved bandwidth for the session. + location (str): The location of the session (lan, wan, or cellular) + """ TAG = 'Session' def _loadData(self, data): @@ -412,7 +455,36 @@ class TranscodeSession(PlexObject): Attributes: TAG (str): 'TranscodeSession' - TODO: Document this. + audioChannels (int): The number of audio channels of the transcoded media. + audioCodec (str): The audio codec of the transcoded media. + audioDecision (str): The transcode decision for the audio stream. + complete (bool): True if the transcode is complete. + container (str): The container of the transcoded media. + context (str): The context for the transcode sesson. + duration (int): The duration of the transcoded media in milliseconds. + height (int): The height of the transcoded media in pixels. + key (str): API URL (ex: /transcode/sessions/). + maxOffsetAvailable (float): Unknown. + minOffsetAvailable (float): Unknown. + progress (float): The progress percentage of the transcode. + protocol (str): The protocol of the transcode. + remaining (int): Unknown. + size (int): The size of the transcoded media in bytes. + sourceAudioCodec (str): The audio codec of the source media. + sourceVideoCodec (str): The video codec of the source media. + speed (float): The speed of the transcode. + subtitleDecision (str): The transcode decision for the subtitle stream + throttled (bool): True if the transcode is throttled. + timestamp (int): The epoch timestamp when the transcode started. + transcodeHwDecoding (str): The hardware transcoding decoder engine. + transcodeHwDecodingTitle (str): The title of the hardware transcoding decoder engine. + transcodeHwEncoding (str): The hardware transcoding encoder engine. + transcodeHwEncodingTitle (str): The title of the hardware transcoding encoder engine. + transcodeHwFullPipeline (str): True if hardware decoding and encoding is being used for the transcode. + transcodeHwRequested (str): True if hardware transcoding was requested for the transcode. + videoCodec (str): The video codec of the transcoded media. + videoDecision (str): The transcode decision for the video stream. + width (str): The width of the transcoded media in pixels. """ TAG = 'TranscodeSession' @@ -422,17 +494,30 @@ class TranscodeSession(PlexObject): self.audioChannels = cast(int, data.attrib.get('audioChannels')) self.audioCodec = data.attrib.get('audioCodec') self.audioDecision = data.attrib.get('audioDecision') + self.complete = cast(bool, data.attrib.get('complete', '0')) 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.maxOffsetAvailable = cast(float, data.attrib.get('maxOffsetAvailable')) + self.minOffsetAvailable = cast(float, data.attrib.get('minOffsetAvailable')) 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.size = cast(int, data.attrib.get('size')) + self.sourceAudioCodec = data.attrib.get('sourceAudioCodec') self.sourceVideoCodec = data.attrib.get('sourceVideoCodec') + self.speed = cast(float, data.attrib.get('speed')) + self.subtitleDecision = data.attrib.get('subtitleDecision') + self.throttled = cast(bool, data.attrib.get('throttled', '0')) + self.timestamp = cast(float, data.attrib.get('timeStamp')) + self.transcodeHwDecoding = data.attrib.get('transcodeHwDecoding') + self.transcodeHwDecodingTitle = data.attrib.get('transcodeHwDecodingTitle') + self.transcodeHwEncoding = data.attrib.get('transcodeHwEncoding') + self.transcodeHwEncodingTitle = data.attrib.get('transcodeHwEncodingTitle') + self.transcodeHwFullPipeline = cast(bool, data.attrib.get('transcodeHwFullPipeline', '0')) + self.transcodeHwRequested = cast(bool, data.attrib.get('transcodeHwRequested', '0')) self.videoCodec = data.attrib.get('videoCodec') self.videoDecision = data.attrib.get('videoDecision') self.width = cast(int, data.attrib.get('width')) @@ -442,7 +527,7 @@ class TranscodeSession(PlexObject): class TranscodeJob(PlexObject): """ Represents an Optimizing job. TrancodeJobs are the process for optimizing conversions. - Active or paused optimization items. Usually one item as a time""" + Active or paused optimization items. Usually one item as a time.""" TAG = 'TranscodeJob' def _loadData(self, data): @@ -598,25 +683,15 @@ class MediaTag(PlexObject): class GuidTag(PlexObject): """ Base class for guid tags used only for Guids, as they contain only a string identifier + Attributes: - server (:class:`~plexapi.server.PlexServer`): Server this client is connected to. - id (id): Tag ID (Used as a unique id, except for Guid's, used for external systems - to plex identifiers, like imdb and tmdb). + id (id): The guid for external metadata sources (e.g. IMDB, TMDB, TVDB). """ def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data self.id = data.attrib.get('id') - self.tag = data.attrib.get('tag') - - 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 @@ -700,7 +775,11 @@ class Genre(MediaTag): @utils.registerPlexObject class Guid(GuidTag): - """ Represents a single Guid media tag. """ + """ Represents a single Guid media tag. + + Attributes: + TAG (str): 'Guid' + """ TAG = "Guid" @@ -741,12 +820,12 @@ class Poster(PlexObject): self._data = data self.key = data.attrib.get('key') self.ratingKey = data.attrib.get('ratingKey') - self.selected = cast(bool, data.attrib.get('selected')) + self.selected = data.attrib.get('selected') self.thumb = data.attrib.get('thumb') def select(self): key = self._initpath[:-1] - data = '%s?url=%s' % (key, compat.quote_plus(self.ratingKey)) + data = '%s?url=%s' % (key, quote_plus(self.ratingKey)) try: self._server.query(data, method=self._server._session.put) except xml.etree.ElementTree.ParseError: @@ -816,7 +895,6 @@ class Chapter(PlexObject): self.filter = data.attrib.get('filter') # I couldn't filter on it anyways self.tag = data.attrib.get('tag') self.title = self.tag - self.thumb = data.attrib.get('thumb') self.index = cast(int, data.attrib.get('index')) self.start = cast(int, data.attrib.get('startTimeOffset')) self.end = cast(int, data.attrib.get('endTimeOffset')) @@ -825,6 +903,7 @@ class Chapter(PlexObject): @utils.registerPlexObject class Marker(PlexObject): """ Represents a single Marker media tag. + Attributes: TAG (str): 'Marker' """ diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index 84064894..09c5caa7 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -2,14 +2,14 @@ import copy import threading import time +from xml.etree import ElementTree import requests from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER, log, logfilter, utils) from plexapi.base import PlexObject -from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.client import PlexClient -from plexapi.compat import ElementTree +from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.library import LibrarySection from plexapi.server import PlexServer from plexapi.sonos import PlexSonosClient @@ -43,7 +43,7 @@ class MyPlexAccount(PlexObject): guest (bool): Unknown. home (bool): Unknown. homeSize (int): Unknown. - id (str): Your Plex account ID. + id (int): Your Plex account ID. locale (str): Your Plex locale mailing_list_status (str): Your current mailing list status. maxHomeSize (int): Unknown. @@ -71,11 +71,12 @@ class MyPlexAccount(PlexObject): PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete - REMOVEINVITE = 'https://plex.tv/api/invites/requested/{userId}?friend=0&server=1&home=0' # delete + REMOVEINVITE = 'https://plex.tv/api/invites/requested/{userId}?friend=1&server=1&home=1' # 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 + LINK = 'https://plex.tv/api/v2/pins/link' # put # Hub sections VOD = 'https://vod.provider.plex.tv/' # get WEBSHOWS = 'https://webshows.provider.plex.tv/' # get @@ -87,7 +88,7 @@ class MyPlexAccount(PlexObject): key = 'https://plex.tv/users/account' def __init__(self, username=None, password=None, token=None, session=None, timeout=None): - self._token = token + self._token = token or CONFIG.get('auth.server_token') self._session = session or requests.Session() self._sonos_cache = [] self._sonos_cache_timestamp = 0 @@ -114,7 +115,7 @@ class MyPlexAccount(PlexObject): 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.id = utils.cast(int, 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')) @@ -139,7 +140,7 @@ class MyPlexAccount(PlexObject): roles = data.find('roles') self.roles = [] - if roles: + if roles is not None: for role in roles.iter('role'): self.roles.append(role.attrib.get('id')) @@ -153,14 +154,15 @@ class MyPlexAccount(PlexObject): self.services = None self.joined_at = None - def device(self, name): + def device(self, name=None, clientId=None): """ Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified. Parameters: name (str): Name to match against. + clientId (str): clientIdentifier to match against. """ for device in self.devices(): - if device.name.lower() == name.lower(): + if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientId): return device raise NotFound('Unable to find device %s' % name) @@ -217,7 +219,7 @@ class MyPlexAccount(PlexObject): return [] t = time.time() - if t - self._sonos_cache_timestamp > 60: + if t - self._sonos_cache_timestamp > 5: self._sonos_cache_timestamp = t data = self.query('https://sonos.plex.tv/resources') self._sonos_cache = [PlexSonosClient(self, elem) for elem in data] @@ -225,10 +227,10 @@ class MyPlexAccount(PlexObject): return self._sonos_cache def sonos_speaker(self, name): - return [x for x in self.sonos_speakers() if x.title == name][0] + return next((x for x in self.sonos_speakers() if x.title.split("+")[0].strip() == name), None) def sonos_speaker_by_id(self, identifier): - return [x for x in self.sonos_speakers() if x.machineIdentifier == identifier][0] + return next((x for x in self.sonos_speakers() if x.machineIdentifier.startswith(identifier)), None) def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): @@ -578,8 +580,8 @@ class MyPlexAccount(PlexObject): :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. Raises: - :exc:`plexapi.exceptions.BadRequest`: when client with provided clientId wasn`t found. - :exc:`plexapi.exceptions.BadRequest`: provided client doesn`t provides `sync-target`. + :exc:`~plexapi.exceptions.BadRequest`: When client with provided clientId wasn`t found. + :exc:`~plexapi.exceptions.BadRequest`: Provided client doesn`t provides `sync-target`. """ if not client and not clientId: clientId = X_PLEX_IDENTIFIER @@ -683,6 +685,19 @@ class MyPlexAccount(PlexObject): elem = ElementTree.fromstring(req.text) return self.findItems(elem) + def link(self, pin): + """ Link a device to the account using a pin code. + + Parameters: + pin (str): The 4 digit link pin code. + """ + headers = { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Plex-Product': 'Plex SSO' + } + data = {'code': pin} + self.query(self.LINK, self._session.put, headers=headers, data=data) + class MyPlexUser(PlexObject): """ This object represents non-signed in users such as friends and linked @@ -942,7 +957,7 @@ class MyPlexResource(PlexObject): HTTP or HTTPS connection. Raises: - :exc:`plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource. + :exc:`~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 @@ -958,7 +973,7 @@ class MyPlexResource(PlexObject): # 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)) + log.debug('Testing %s resource connections..', len(listargs)) results = utils.threaded(_connect, listargs) return _chooseConnection('Resource', self.name, results) @@ -1049,11 +1064,11 @@ class MyPlexDevice(PlexObject): at least one connection was successful, the PlexClient object is built and returned. Raises: - :exc:`plexapi.exceptions.NotFound`: When unable to connect to any addresses for this device. + :exc:`~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)) + log.debug('Testing %s device connections..', len(listargs)) results = utils.threaded(_connect, listargs) return _chooseConnection('Device', self.name, results) @@ -1066,7 +1081,7 @@ class MyPlexDevice(PlexObject): """ Returns an instance of :class:`~plexapi.sync.SyncList` for current device. Raises: - :exc:`plexapi.exceptions.BadRequest`: when the device doesn`t provides `sync-target`. + :exc:`~plexapi.exceptions.BadRequest`: when the device doesn`t provides `sync-target`. """ if 'sync-target' not in self.provides: raise BadRequest('Requested syncList for device which do not provides sync-target') @@ -1098,33 +1113,40 @@ class MyPlexPinLogin(object): requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT). Attributes: - PINS (str): 'https://plex.tv/pins.xml' - CHECKPINS (str): 'https://plex.tv/pins/{pinid}.xml' + PINS (str): 'https://plex.tv/api/v2/pins' + CHECKPINS (str): 'https://plex.tv/api/v2/pins/{pinid}' + LINK (str): 'https://plex.tv/api/v2/pins/link' POLLINTERVAL (int): 1 finished (bool): Whether the pin login has finished or not. expired (bool): Whether the pin login has expired or not. token (str): Token retrieved through the pin login. pin (str): Pin to use for the login on https://plex.tv/link. """ - PINS = 'https://plex.tv/pins.xml' # get - CHECKPINS = 'https://plex.tv/pins/{pinid}.xml' # get + PINS = 'https://plex.tv/api/v2/pins' # get + CHECKPINS = 'https://plex.tv/api/v2/pins/{pinid}' # get POLLINTERVAL = 1 - def __init__(self, session=None, requestTimeout=None): + def __init__(self, session=None, requestTimeout=None, headers=None): super(MyPlexPinLogin, self).__init__() self._session = session or requests.Session() self._requestTimeout = requestTimeout or TIMEOUT + self.headers = headers self._loginTimeout = None self._callback = None self._thread = None self._abort = False self._id = None + self._code = None + self._getCode() self.finished = False self.expired = False self.token = None - self.pin = self._getPin() + + @property + def pin(self): + return self._code def run(self, callback=None, timeout=None): """ Starts the thread which monitors the PIN login state. @@ -1133,8 +1155,8 @@ class MyPlexPinLogin(object): timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional). Raises: - :class:`RuntimeError`: if the thread is already running. - :class:`RuntimeError`: if the PIN login for the current PIN has expired. + :class:`RuntimeError`: If the thread is already running. + :class:`RuntimeError`: If the PIN login for the current PIN has expired. """ if self._thread and not self._abort: raise RuntimeError('MyPlexPinLogin thread is already running') @@ -1187,19 +1209,16 @@ class MyPlexPinLogin(object): return False - def _getPin(self): - if self.pin: - return self.pin - + def _getCode(self): url = self.PINS response = self._query(url, self._session.post) if not response: return None - self._id = response.find('id').text - self.pin = response.find('code').text + self._id = response.attrib.get('id') + self._code = response.attrib.get('code') - return self.pin + return self._code def _checkLogin(self): if not self._id: @@ -1213,7 +1232,7 @@ class MyPlexPinLogin(object): if not response: return False - token = response.find('auth_token').text + token = response.attrib.get('authToken') if not token: return False @@ -1241,11 +1260,19 @@ class MyPlexPinLogin(object): finally: self.finished = True - def _query(self, url, method=None): + def _headers(self, **kwargs): + """ Returns dict containing base headers for all requests for pin login. """ + headers = BASE_HEADERS.copy() + if self.headers: + headers.update(self.headers) + headers.update(kwargs) + return headers + + def _query(self, url, method=None, headers=None, **kwargs): method = method or self._session.get log.debug('%s %s', method.__name__.upper(), url) - headers = BASE_HEADERS.copy() - response = method(url, headers=headers, timeout=self._requestTimeout) + headers = headers or self._headers() + response = method(url, headers=headers, timeout=self._requestTimeout, **kwargs) if not response.ok: # pragma: no cover codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') @@ -1288,9 +1315,9 @@ def _chooseConnection(ctype, name, results): # 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) + log.debug('%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) + log.debug('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 index 80720495..49d640bc 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -1,157 +1,224 @@ # -*- coding: utf-8 -*- -from plexapi import media, utils -from plexapi.base import PlexPartialObject -from plexapi.exceptions import NotFound, BadRequest -from plexapi.compat import quote_plus +from urllib.parse import quote_plus + +from plexapi import media, utils, video +from plexapi.base import Playable, PlexPartialObject +from plexapi.exceptions import BadRequest @utils.registerPlexObject class Photoalbum(PlexPartialObject): - """ Represents a photoalbum (collection of photos). + """ Represents a single 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. + addedAt (datetime): Datetime the photo album was added to the library. + art (str): URL to artwork image (/library/metadata//art/). + composite (str): URL to composite image (/library/metadata//composite/) + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the photo album (local://229674). + index (sting): Plex index number for the photo album. key (str): API URL (/library/metadata/). librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. listType (str): Hardcoded as 'photo' (useful for search filters). - ratingKey (int): Unique key identifying this item. + ratingKey (int): Unique key identifying the photo album. 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. + thumb (str): URL to thumbnail image (/library/metadata//thumb/). + title (str): Name of the photo album. (Trip to Disney World) + titleSort (str): Title to use when sorting (defaults to title). + type (str): 'photo' + updatedAt (datatime): Datetime the photo album was updated. + userRating (float): Rating of the photoalbum (0.0 - 10.0) equaling (0 stars - 5 stars). """ 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.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') self.index = utils.cast(int, data.attrib.get('index')) - self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 + self.key = data.attrib.get('key', '') self.librarySectionID = data.attrib.get('librarySectionID') self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') - self.ratingKey = data.attrib.get('ratingKey') + self.listType = 'photo' + 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.titleSort = data.attrib.get('titleSort', self.title) self.type = data.attrib.get('type') self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) - self.fields = self.findItems(data, media.Field) - - 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) + self.userRating = utils.cast(float, data.attrib.get('userRating', 0)) 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) + """ Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title. - def photos(self, **kwargs): - """ Returns a list of :class:`~plexapi.photo.Photo` objects in this album. """ + Parameters: + title (str): Title of the photo album to return. + """ key = '/library/metadata/%s/children' % self.ratingKey - return self.fetchItems(key, etag='Photo', **kwargs) + return self.fetchItem(key, Photoalbum, title__iexact=title) + + def albums(self, **kwargs): + """ Returns a list of :class:`~plexapi.photo.Photoalbum` objects in the album. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, Photoalbum, **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) + """ Returns the :class:`~plexapi.photo.Photo` that matches the specified title. + + Parameters: + title (str): Title of the photo to return. + """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItem(key, Photo, title__iexact=title) + + def photos(self, **kwargs): + """ Returns a list of :class:`~plexapi.photo.Photo` objects in the album. """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItems(key, Photo, **kwargs) + + def clip(self, title): + """ Returns the :class:`~plexapi.video.Clip` that matches the specified title. + + Parameters: + title (str): Title of the clip to return. + """ + key = '/library/metadata/%s/children' % self.ratingKey + return self.fetchItem(key, video.Clip, title__iexact=title) def clips(self, **kwargs): - """ Returns a list of :class:`~plexapi.video.Clip` objects in this album. """ + """ Returns a list of :class:`~plexapi.video.Clip` objects in the album. """ key = '/library/metadata/%s/children' % self.ratingKey - return self.fetchItems(key, etag='Video', **kwargs) + return self.fetchItems(key, video.Clip, **kwargs) + + def get(self, title): + """ Alias to :func:`~plexapi.photo.Photoalbum.photo`. """ + return self.episode(title) + + def iterParts(self): + """ Iterates over the parts of the media item. """ + for album in self.albums(): + for photo in album.photos(): + for part in photo.iterParts(): + yield part + + def download(self, savepath=None, keep_original_name=False, showstatus=False): + """ Download photo files to specified directory. + + Parameters: + savepath (str): Defaults to current working dir. + keep_original_name (bool): True to keep the original file name otherwise + a friendlier is generated. + showstatus(bool): Display a progressbar. + """ + filepaths = [] + locations = [i for i in self.iterParts() if i] + for location in locations: + name = location.file + if not keep_original_name: + title = self.title.replace(' ', '.') + name = '%s.%s' % (title, location.container) + url = self._server.url('%s?download=1' % location.key) + filepath = utils.download(url, self._server._token, filename=name, showstatus=showstatus, + savepath=savepath, session=self._server._session) + if filepath: + filepaths.append(filepath) + return filepaths @utils.registerPlexObject -class Photo(PlexPartialObject): - """ Represents a single photo. +class Photo(PlexPartialObject, Playable): + """ 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. + addedAt (datetime): Datetime the photo was added to the library. + createdAtAccuracy (str): Unknown (local). + createdAtTZOffset (int): Unknown (-25200). + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the photo (com.plexapp.agents.none://231714?lang=xn). + index (sting): Plex index number for the photo. key (str): API URL (/library/metadata/). + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. 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. + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originallyAvailableAt (datetime): Datetime the photo was added to Plex. + parentGuid (str): Plex GUID for the photo album (local://229674). + parentIndex (int): Plex index number for the photo album. + parentKey (str): API URL of the photo album (/library/metadata/). + parentRatingKey (int): Unique key identifying the photo album. + parentThumb (str): URL to photo album thumbnail image (/library/metadata//thumb/). + parentTitle (str): Name of the photo album for the photo. + ratingKey (int): Unique key identifying the photo. 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 (List<:class:`~plexapi.media.Tag`>): List of tag objects. + thumb (str): URL to thumbnail image (/library/metadata//thumb/). + title (str): Name of the photo. + titleSort (str): Title to use when sorting (defaults to title). + type (str): 'photo' + updatedAt (datatime): Datetime the photo was updated. + year (int): Year the photo was taken. """ TAG = 'Photo' TYPE = 'photo' METADATA_TYPE = 'photo' - _include = ('?checkFiles=1&includeExtras=1&includeRelated=1' - '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1' - '&includeMarkers=1&includeConcerts=1&includePreferences=1' - '&includeBandwidths=1&includeLoudnessRamps=1') - def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self.key = data.attrib.get('key') - self._details_key = self.key + self._include - self.listType = 'photo' + Playable._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.createdAtAccuracy = data.attrib.get('createdAtAccuracy') self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset')) + self.fields = self.findItems(data, media.Field) 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.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') - self.originallyAvailableAt = utils.toDatetime( - data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') + self.listType = 'photo' + self.media = self.findItems(data, media.Media) + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.parentGuid = data.attrib.get('parentGuid') self.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) self.parentKey = data.attrib.get('parentKey') - self.parentRatingKey = data.attrib.get('parentRatingKey') + self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') - self.ratingKey = data.attrib.get('ratingKey') + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.summary = data.attrib.get('summary') + self.tag = self.findItems(data, media.Tag) self.thumb = data.attrib.get('thumb') self.title = data.attrib.get('title') - self.titleSort = data.attrib.get('titleSort') + self.titleSort = data.attrib.get('titleSort', self.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) - self.tag = self.findItems(data, media.Tag) - self.fields = self.findItems(data, media.Field) + + @property + def thumbUrl(self): + """Return URL for the thumbnail image.""" + key = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') + return self._server.url(key, includeToken=True) if key else None def photoalbum(self): - """ Return this photo's :class:`~plexapi.photo.Photoalbum`. """ + """ Return the photo's :class:`~plexapi.photo.Photoalbum`. """ return self.fetchItem(self.parentKey) def section(self): - """ Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """ + """ Returns the :class:`~plexapi.library.LibrarySection` the item belongs to. """ if hasattr(self, 'librarySectionID'): return self._server.library.sectionByID(self.librarySectionID) elif self.parentKey: @@ -162,10 +229,19 @@ class Photo(PlexPartialObject): @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 Photo + interface to get the locations of the photo. + + Retruns: + List of file paths where the photo is found on disk. """ return [part.file for item in self.media for part in item.parts if part] + def iterParts(self): + """ Iterates over the parts of the media item. """ + for item in self.media: + for part in item.parts: + yield part + def sync(self, resolution, client=None, clientId=None, limit=None, title=None): """ Add current photo as sync item for specified device. See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. @@ -201,3 +277,26 @@ class Photo(PlexPartialObject): sync_item.mediaSettings = MediaSettings.createPhoto(resolution) return myplex.sync(sync_item, client=client, clientId=clientId) + + def download(self, savepath=None, keep_original_name=False, showstatus=False): + """ Download photo files to specified directory. + + Parameters: + savepath (str): Defaults to current working dir. + keep_original_name (bool): True to keep the original file name otherwise + a friendlier is generated. + showstatus(bool): Display a progressbar. + """ + filepaths = [] + locations = [i for i in self.iterParts() if i] + for location in locations: + name = location.file + if not keep_original_name: + title = self.title.replace(' ', '.') + name = '%s.%s' % (title, location.container) + url = self._server.url('%s?download=1' % location.key) + filepath = utils.download(url, self._server._token, filename=name, showstatus=showstatus, + savepath=savepath, session=self._server._session) + if filepath: + filepaths.append(filepath) + return filepaths diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index 319d9123..9e691b52 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -1,17 +1,36 @@ # -*- coding: utf-8 -*- +from urllib.parse import quote_plus + from plexapi import utils -from plexapi.base import PlexPartialObject, Playable -from plexapi.exceptions import BadRequest, Unsupported +from plexapi.base import Playable, PlexPartialObject +from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection from plexapi.playqueue import PlayQueue from plexapi.utils import cast, toDatetime -from plexapi.compat import quote_plus @utils.registerPlexObject class Playlist(PlexPartialObject, Playable): - """ Represents a single Playlist object. - # TODO: Document attributes + """ Represents a single Playlist. + + Attributes: + TAG (str): 'Playlist' + TYPE (str): 'playlist' + addedAt (datetime): Datetime the playlist was added to the server. + allowSync (bool): True if you allow syncing playlists. + composite (str): URL to composite image (/playlist//composite/) + duration (int): Duration of the playlist in milliseconds. + durationInSeconds (int): Duration of the playlist in seconds. + guid (str): Plex GUID for the playlist (com.plexapp.agents.none://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX). + key (str): API URL (/playlist/). + leafCount (int): Number of items in the playlist view. + playlistType (str): 'audio', 'video', or 'photo' + ratingKey (int): Unique key identifying the playlist. + smart (bool): True if the playlist is a smart playlist. + summary (str): Summary of the playlist. + title (str): Name of the playlist. + type (str): 'playlist' + updatedAt (datatime): Datetime the playlist was updated. """ TAG = 'Playlist' TYPE = 'playlist' @@ -20,12 +39,12 @@ class Playlist(PlexPartialObject, Playable): """ Load attribute values from Plex XML response. """ Playable._loadData(self, data) self.addedAt = toDatetime(data.attrib.get('addedAt')) + self.allowSync = cast(bool, data.attrib.get('allowSync')) 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.key = data.attrib.get('key', '').replace('/items', '') # 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')) @@ -34,12 +53,15 @@ class Playlist(PlexPartialObject, Playable): self.title = data.attrib.get('title') self.type = data.attrib.get('type') self.updatedAt = toDatetime(data.attrib.get('updatedAt')) - self.allowSync = cast(bool, data.attrib.get('allowSync')) self._items = None # cache for self.items def __len__(self): # pragma: no cover return len(self.items()) + def __iter__(self): # pragma: no cover + for item in self.items(): + yield item + @property def metadataType(self): if self.isVideo: @@ -69,14 +91,29 @@ class Playlist(PlexPartialObject, Playable): def __getitem__(self, key): # pragma: no cover return self.items()[key] + def item(self, title): + """ Returns the item in the playlist that matches the specified title. + + Parameters: + title (str): Title of the item to return. + """ + for item in self.items(): + if item.title.lower() == title.lower(): + return item + raise NotFound('Item with title "%s" not found in the playlist' % title) + def items(self): """ Returns a list of all items in the playlist. """ if self._items is None: - key = '%s/items' % self.key + key = '/playlists/%s/items' % self.ratingKey items = self.fetchItems(key) self._items = items return self._items + def get(self, title): + """ Alias to :func:`~plexapi.playlist.Playlist.item`. """ + return self.item(title) + def addItems(self, items): """ Add items to a playlist. """ if not isinstance(items, (list, tuple)): @@ -130,6 +167,9 @@ class Playlist(PlexPartialObject, Playable): @classmethod def _create(cls, server, title, items): """ Create a playlist. """ + if not items: + raise BadRequest('Must include items to add when creating new playlist') + if items and not isinstance(items, (list, tuple)): items = [items] ratingKeys = [] @@ -161,6 +201,9 @@ class Playlist(PlexPartialObject, Playable): smart (bool): default False. **kwargs (dict): is passed to the filters. For a example see the search method. + + Raises: + :class:`plexapi.exceptions.BadRequest`: when no items are included in create request. Returns: :class:`~plexapi.playlist.Playlist`: an instance of created Playlist. @@ -235,8 +278,8 @@ class Playlist(PlexPartialObject, Playable): generated from metadata of current photo. Raises: - :exc:`plexapi.exceptions.BadRequest`: when playlist is not allowed to sync. - :exc:`plexapi.exceptions.Unsupported`: when playlist content is unsupported. + :exc:`~plexapi.exceptions.BadRequest`: When playlist is not allowed to sync. + :exc:`~plexapi.exceptions.Unsupported`: When playlist content is unsupported. Returns: :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. diff --git a/lib/plexapi/playqueue.py b/lib/plexapi/playqueue.py index 08aa774c..ca6fda63 100644 --- a/lib/plexapi/playqueue.py +++ b/lib/plexapi/playqueue.py @@ -1,75 +1,289 @@ # -*- coding: utf-8 -*- +from urllib.parse import quote_plus + from plexapi import utils from plexapi.base import PlexObject +from plexapi.exceptions import BadRequest, Unsupported class PlayQueue(PlexObject): - """ Control a PlayQueue. + """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. + Attributes: + TAG (str): 'PlayQueue' + TYPE (str): 'playqueue' + identifier (str): com.plexapp.plugins.library + items (list): List of :class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist` + mediaTagPrefix (str): Fx /system/bundle/media/flags/ + mediaTagVersion (int): Fx 1485957738 + playQueueID (int): ID of the PlayQueue. + playQueueLastAddedItemID (int): + Defines where the "Up Next" region starts. Empty unless PlayQueue is modified after creation. + playQueueSelectedItemID (int): The queue item ID of the currently selected item. + playQueueSelectedItemOffset (int): + The offset of the selected item in the PlayQueue, from the beginning of the queue. + playQueueSelectedMetadataItemID (int): ID of the currently selected item, matches ratingKey. + playQueueShuffled (bool): True if shuffled. + playQueueSourceURI (str): Original URI used to create the PlayQueue. + playQueueTotalCount (int): How many items in the PlayQueue. + playQueueVersion (int): Version of the PlayQueue. Increments every time a change is made to the PlayQueue. + selectedItem (:class:`~plexapi.media.Media`): Media object for the currently selected item. + _server (:class:`~plexapi.server.PlexServer`): PlexServer associated with the PlayQueue. + size (int): Alias for playQueueTotalCount. """ + TAG = "PlayQueue" + TYPE = "playqueue" + 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.identifier = data.attrib.get("identifier") + self.mediaTagPrefix = data.attrib.get("mediaTagPrefix") + self.mediaTagVersion = utils.cast(int, data.attrib.get("mediaTagVersion")) + self.playQueueID = utils.cast(int, data.attrib.get("playQueueID")) + self.playQueueLastAddedItemID = utils.cast( + int, data.attrib.get("playQueueLastAddedItemID") + ) + self.playQueueSelectedItemID = utils.cast( + int, data.attrib.get("playQueueSelectedItemID") + ) + self.playQueueSelectedItemOffset = utils.cast( + int, data.attrib.get("playQueueSelectedItemOffset") + ) + self.playQueueSelectedMetadataItemID = utils.cast( + int, data.attrib.get("playQueueSelectedMetadataItemID") + ) + self.playQueueShuffled = utils.cast( + bool, data.attrib.get("playQueueShuffled", 0) + ) + self.playQueueSourceURI = data.attrib.get("playQueueSourceURI") + self.playQueueTotalCount = utils.cast( + int, data.attrib.get("playQueueTotalCount") + ) + self.playQueueVersion = utils.cast(int, data.attrib.get("playQueueVersion")) + self.size = utils.cast(int, data.attrib.get("size", 0)) self.items = self.findItems(data) + self.selectedItem = self[self.playQueueSelectedItemOffset] + + def __getitem__(self, key): + if not self.items: + return None + return self.items[key] + + def __len__(self): + return self.playQueueTotalCount + + def __iter__(self): + yield from self.items + + def __contains__(self, media): + """Returns True if the PlayQueue contains the provided media item.""" + return any(x.playQueueItemID == media.playQueueItemID for x in self.items) + + def getQueueItem(self, item): + """ + Accepts a media item and returns a similar object from this PlayQueue. + Useful for looking up playQueueItemIDs using items obtained from the Library. + """ + matches = [x for x in self.items if x == item] + if len(matches) == 1: + return matches[0] + elif len(matches) > 1: + raise BadRequest( + "{item} occurs multiple times in this PlayQueue, provide exact item".format(item=item) + ) + else: + raise BadRequest("{item} not valid for this PlayQueue".format(item=item)) @classmethod - def create(cls, server, item, shuffle=0, repeat=0, includeChapters=1, includeRelated=1): - """ Create and returns a new :class:`~plexapi.playqueue.PlayQueue`. + def get( + cls, + server, + playQueueID, + own=False, + center=None, + window=50, + includeBefore=True, + includeAfter=True, + ): + """Retrieve an existing :class:`~plexapi.playqueue.PlayQueue` by identifier. - 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. + Parameters: + server (:class:`~plexapi.server.PlexServer`): Server you are connected to. + playQueueID (int): Identifier of an existing PlayQueue. + own (bool, optional): If server should transfer ownership. + center (int, optional): The playQueueItemID of the center of the window. Does not change selectedItem. + window (int, optional): Number of items to return from each side of the center item. + includeBefore (bool, optional): + Include items before the center, defaults True. Does not include center if False. + includeAfter (bool, optional): + Include items after the center, defaults True. Does not include center if False. """ - 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 + args = { + "own": utils.cast(int, own), + "window": window, + "includeBefore": utils.cast(int, includeBefore), + "includeAfter": utils.cast(int, includeAfter), + } + if center: + args["center"] = center + + path = "/playQueues/{playQueueID}{args}".format(playQueueID=playQueueID, args=utils.joinArgs(args)) + data = server.query(path, method=server._session.get) + c = cls(server, data, initpath=path) + c._server = server + return c + + @classmethod + def create( + cls, + server, + items, + startItem=None, + shuffle=0, + repeat=0, + includeChapters=1, + includeRelated=1, + continuous=0, + ): + """Create and return a new :class:`~plexapi.playqueue.PlayQueue`. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): Server you are connected to. + items (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`): + A media item, list of media items, or Playlist. + startItem (:class:`~plexapi.media.Media`, optional): + Media item in the PlayQueue where playback should begin. + shuffle (int, optional): Start the playqueue shuffled. + repeat (int, optional): Start the playqueue shuffled. + includeChapters (int, optional): include Chapters. + includeRelated (int, optional): include Related. + continuous (int, optional): include additional items after the initial item. + For a show this would be the next episodes, for a movie it does nothing. + """ + args = { + "includeChapters": includeChapters, + "includeRelated": includeRelated, + "repeat": repeat, + "shuffle": shuffle, + "continuous": continuous, + } + + if isinstance(items, list): + item_keys = ",".join([str(x.ratingKey) for x in items]) + uri_args = quote_plus("/library/metadata/{item_keys}".format(item_keys=item_keys)) + args["uri"] = "library:///directory/{uri_args}".format(uri_args=uri_args) + args["type"] = items[0].listType + elif items.type == "playlist": + args["playlistID"] = items.ratingKey + args["type"] = items.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) + uuid = items.section().uuid + args["type"] = items.listType + args["uri"] = "library://{uuid}/item/{key}".format(uuid=uuid, key=items.key) + + if startItem: + args["key"] = startItem.key + + path = "/playQueues{args}".format(args=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 + c.playQueueType = args["type"] + c._server = server return c + + def addItem(self, item, playNext=False, refresh=True): + """ + Append the provided item to the "Up Next" section of the PlayQueue. + Items can only be added to the section immediately following the current playing item. + + Parameters: + item (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`): Single media item or Playlist. + playNext (bool, optional): If True, add this item to the front of the "Up Next" section. + If False, the item will be appended to the end of the "Up Next" section. + Only has an effect if an item has already been added to the "Up Next" section. + See https://support.plex.tv/articles/202188298-play-queues/ for more details. + refresh (bool, optional): Refresh the PlayQueue from the server before updating. + """ + if refresh: + self.refresh() + + args = {} + if item.type == "playlist": + args["playlistID"] = item.ratingKey + itemType = item.playlistType + else: + uuid = item.section().uuid + itemType = item.listType + args["uri"] = "library://{uuid}/item{key}".format(uuid=uuid, key=item.key) + + if itemType != self.playQueueType: + raise Unsupported("Item type does not match PlayQueue type") + + if playNext: + args["next"] = 1 + + path = "/playQueues/{playQueueID}{args}".format(playQueueID=self.playQueueID, args=utils.joinArgs(args)) + data = self._server.query(path, method=self._server._session.put) + self._loadData(data) + + def moveItem(self, item, after=None, refresh=True): + """ + Moves an item to the beginning of the PlayQueue. If `after` is provided, + the item will be placed immediately after the specified item. + + Parameters: + item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move. + afterItemID (:class:`~plexapi.base.Playable`, optional): A different item in the PlayQueue. + If provided, `item` will be placed in the PlayQueue after this item. + refresh (bool, optional): Refresh the PlayQueue from the server before updating. + """ + args = {} + + if refresh: + self.refresh() + + if item not in self: + item = self.getQueueItem(item) + + if after: + if after not in self: + after = self.getQueueItem(after) + args["after"] = after.playQueueItemID + + path = "/playQueues/{playQueueID}/items/{playQueueItemID}/move{args}".format( + playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID, args=utils.joinArgs(args) + ) + data = self._server.query(path, method=self._server._session.put) + self._loadData(data) + + def removeItem(self, item, refresh=True): + """Remove an item from the PlayQueue. + + Parameters: + item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move. + refresh (bool, optional): Refresh the PlayQueue from the server before updating. + """ + if refresh: + self.refresh() + + if item not in self: + item = self.getQueueItem(item) + + path = "/playQueues/{playQueueID}/items/{playQueueItemID}".format( + playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID + ) + data = self._server.query(path, method=self._server._session.delete) + self._loadData(data) + + def clear(self): + """Remove all items from the PlayQueue.""" + path = "/playQueues/{playQueueID}/items".format(playQueueID=self.playQueueID) + data = self._server.query(path, method=self._server._session.delete) + self._loadData(data) + + def refresh(self): + """Refresh the PlayQueue from the Plex server.""" + path = "/playQueues/{playQueueID}".format(playQueueID=self.playQueueID) + data = self._server.query(path, method=self._server._session.get) + self._loadData(data) diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index 7b06bf92..42c92fdc 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -1,23 +1,29 @@ # -*- coding: utf-8 -*- +from urllib.parse import urlencode +from xml.etree import ElementTree + import requests -from requests.status_codes import _codes as codes -from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE -from plexapi import log, logfilter, utils +from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, log, + logfilter) +from plexapi import 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, Unauthorized from plexapi.library import Hub, Library, Path, File -from plexapi.settings import Settings +from plexapi.media import Conversion, Optimized from plexapi.playlist import Playlist from plexapi.playqueue import PlayQueue +from plexapi.settings import Settings from plexapi.utils import cast -from plexapi.media import Optimized, Conversion +from requests.status_codes import _codes as codes # 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 +from plexapi import audio as _audio # noqa: F401; noqa: F401 +from plexapi import media as _media # noqa: F401 +from plexapi import photo as _photo # noqa: F401 +from plexapi import playlist as _playlist # noqa: F401 +from plexapi import video as _video # noqa: F401 class PlexServer(PlexObject): @@ -101,6 +107,8 @@ class PlexServer(PlexObject): self._library = None # cached library self._settings = None # cached settings self._myPlexAccount = None # cached myPlexAccount + self._systemAccounts = None # cached list of SystemAccount + self._systemDevices = None # cached list of SystemDevice data = self.query(self.key, timeout=timeout) super(PlexServer, self).__init__(self, data, self.key) @@ -184,6 +192,14 @@ class PlexServer(PlexObject): data = self.query(Account.key) return Account(self, data) + @property + def activities(self): + """Returns all current PMS activities.""" + activities = [] + for elem in self.query(Activity.key): + activities.append(Activity(self, elem)) + return activities + def agents(self, mediaType=None): """ Returns the :class:`~plexapi.media.Agent` objects this server has available. """ key = '/system/agents' @@ -200,11 +216,18 @@ class PlexServer(PlexObject): return q.attrib.get('token') def systemAccounts(self): - """ Returns the :class:`~plexapi.server.SystemAccounts` objects this server contains. """ - accounts = [] - for elem in self.query('/accounts'): - accounts.append(SystemAccount(self, data=elem)) - return accounts + """ Returns a list of :class:`~plexapi.server.SystemAccounts` objects this server contains. """ + if self._systemAccounts is None: + key = '/accounts' + self._systemAccounts = self.fetchItems(key, SystemAccount) + return self._systemAccounts + + def systemDevices(self): + """ Returns a list of :class:`~plexapi.server.SystemDevices` objects this server contains. """ + if self._systemDevices is None: + key = '/devices' + self._systemDevices = self.fetchItems(key, SystemDevice) + return self._systemDevices def myPlexAccount(self): """ Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same @@ -303,7 +326,7 @@ class PlexServer(PlexObject): name (str): Name of the client to return. Raises: - :exc:`plexapi.exceptions.NotFound`: Unknown client name + :exc:`~plexapi.exceptions.NotFound`: Unknown client name. """ for client in self.clients(): if client and client.title == name: @@ -325,7 +348,7 @@ class PlexServer(PlexObject): Parameters: item (Media or Playlist): Media or playlist to add to PlayQueue. - kwargs (dict): See `~plexapi.playerque.PlayQueue.create`. + kwargs (dict): See `~plexapi.playqueue.PlayQueue.create`. """ return PlayQueue.create(self, item, **kwargs) @@ -413,11 +436,11 @@ class PlexServer(PlexObject): args['X-Plex-Container-Start'] += args['X-Plex-Container-Size'] return results - def playlists(self, **kwargs): + 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', **kwargs) + return self.fetchItems('/playlists') def playlist(self, title): """ Returns the :class:`~plexapi.client.Playlist` that matches the specified title. @@ -426,7 +449,7 @@ class PlexServer(PlexObject): title (str): Title of the playlist to return. Raises: - :exc:`plexapi.exceptions.NotFound`: Invalid playlist title + :exc:`~plexapi.exceptions.NotFound`: Invalid playlist title. """ return self.fetchItem('/playlists', title=title) @@ -471,7 +494,7 @@ class PlexServer(PlexObject): 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): + if response.status_code not in (200, 201, 204): codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext) @@ -499,23 +522,34 @@ class PlexServer(PlexObject): Parameters: query (str): Query to use when searching your library. mediatype (str): Optionally limit your search to the specified media type. + actor, album, artist, autotag, collection, director, episode, game, genre, + movie, photo, photoalbum, place, playlist, shared, show, tag, track limit (int): Optionally limit to the specified number of results per Hub. """ results = [] - params = {'query': query} - if mediatype: - params['section'] = utils.SEARCHTYPES[mediatype] + params = { + 'query': query, + 'includeCollections': 1, + 'includeExternalMedia': 1} if limit: params['limit'] = limit key = '/hubs/search?%s' % urlencode(params) for hub in self.fetchItems(key, Hub): - results += hub.items + if mediatype: + if hub.type == mediatype: + return hub.items + else: + 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 transcodeSessions(self): + """ Returns a list of all active :class:`~plexapi.media.TranscodeSession` objects. """ + return self.fetchItems('/transcode/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 @@ -528,7 +562,7 @@ class PlexServer(PlexObject): callback (func): Callback function to call on recieved messages. Raises: - :exc:`plexapi.exception.Unsupported`: Websocket-client not installed. + :exc:`~plexapi.exception.Unsupported`: Websocket-client not installed. """ notifier = AlertListener(self, callback) notifier.start() @@ -593,6 +627,103 @@ class PlexServer(PlexObject): value = 1 if toggle is True else 0 return self.query('/:/prefs?allowMediaDeletion=%s' % value, self._session.put) + def bandwidth(self, timespan=None, **kwargs): + """ Returns a list of :class:`~plexapi.server.StatisticsBandwidth` objects + with the Plex server dashboard bandwidth data. + + Parameters: + timespan (str, optional): The timespan to bin the bandwidth data. Default is seconds. + Available timespans: seconds, hours, days, weeks, months. + **kwargs (dict, optional): Any of the available filters that can be applied to the bandwidth data. + The time frame (at) and bytes can also be filtered using less than or greater than (see examples below). + + * accountID (int): The :class:`~plexapi.server.SystemAccount` ID to filter. + * at (datetime): The time frame to filter (inclusive). The time frame can be either: + 1. An exact time frame (e.g. Only December 1st 2020 `at=datetime(2020, 12, 1)`). + 2. Before a specific time (e.g. Before and including December 2020 `at<=datetime(2020, 12, 1)`). + 3. After a specific time (e.g. After and including January 2021 `at>=datetime(2021, 1, 1)`). + * bytes (int): The amount of bytes to filter (inclusive). The bytes can be either: + 1. An exact number of bytes (not very useful) (e.g. `bytes=1024**3`). + 2. Less than or equal number of bytes (e.g. `bytes<=1024**3`). + 3. Greater than or equal number of bytes (e.g. `bytes>=1024**3`). + * deviceID (int): The :class:`~plexapi.server.SystemDevice` ID to filter. + * lan (bool): True to only retrieve local bandwidth, False to only retrieve remote bandwidth. + Default returns all local and remote bandwidth. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When applying an invalid timespan or unknown filter. + + Example: + + .. code-block:: python + + from plexapi.server import PlexServer + plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx') + + # Filter bandwidth data for December 2020 and later, and more than 1 GB used. + filters = { + 'at>': datetime(2020, 12, 1), + 'bytes>': 1024**3 + } + + # Retrieve bandwidth data in one day timespans. + bandwidthData = plex.bandwidth(timespan='days', **filters) + + # Print out bandwidth usage for each account and device combination. + for bandwidth in sorted(bandwidthData, key=lambda x: x.at): + account = bandwidth.account() + device = bandwidth.device() + gigabytes = round(bandwidth.bytes / 1024**3, 3) + local = 'local' if bandwidth.lan else 'remote' + date = bandwidth.at.strftime('%Y-%m-%d') + print('%s used %s GB of %s bandwidth on %s from %s' + % (account.name, gigabytes, local, date, device.name)) + + """ + params = {} + + if timespan is None: + params['timespan'] = 6 # Default to seconds + else: + timespans = { + 'seconds': 6, + 'hours': 4, + 'days': 3, + 'weeks': 2, + 'months': 1 + } + try: + params['timespan'] = timespans[timespan] + except KeyError: + raise BadRequest('Invalid timespan specified: %s. ' + 'Available timespans: %s' % (timespan, ', '.join(timespans.keys()))) + + filters = {'accountID', 'at', 'at<', 'at>', 'bytes', 'bytes<', 'bytes>', 'deviceID', 'lan'} + + for key, value in kwargs.items(): + if key not in filters: + raise BadRequest('Unknown filter: %s=%s' % (key, value)) + if key.startswith('at'): + try: + value = cast(int, value.timestamp()) + except AttributeError: + raise BadRequest('Time frame filter must be a datetime object: %s=%s' % (key, value)) + elif key.startswith('bytes') or key == 'lan': + value = cast(int, value) + elif key == 'accountID': + if value == self.myPlexAccount().id: + value = 1 # The admin account is accountID=1 + params[key] = value + + key = '/statistics/bandwidth?%s' % urlencode(params) + return self.fetchItems(key, StatisticsBandwidth) + + def resources(self): + """ Returns a list of :class:`~plexapi.server.StatisticsResources` objects + with the Plex server dashboard resources data. """ + key = '/statistics/resources?timespan=6' + return self.fetchItems(key, StatisticsResources) + class Account(PlexObject): """ Contains the locally cached MyPlex account information. The properties provided don't @@ -642,12 +773,148 @@ class Account(PlexObject): self.subscriptionState = data.attrib.get('subscriptionState') -class SystemAccount(PlexObject): - """ Minimal api to list system accounts. """ - key = '/accounts' +class Activity(PlexObject): + """A currently running activity on the PlexServer.""" + key = '/activities' def _loadData(self, data): self._data = data - self.accountID = cast(int, data.attrib.get('id')) - self.accountKey = data.attrib.get('key') + self.cancellable = cast(bool, data.attrib.get('cancellable')) + self.progress = cast(int, data.attrib.get('progress')) + self.title = data.attrib.get('title') + self.subtitle = data.attrib.get('subtitle') + self.type = data.attrib.get('type') + self.uuid = data.attrib.get('uuid') + + +class SystemAccount(PlexObject): + """ Represents a single system account. + + Attributes: + TAG (str): 'Account' + autoSelectAudio (bool): True or False if the account has automatic audio language enabled. + defaultAudioLanguage (str): The default audio language code for the account. + defaultSubtitleLanguage (str): The default subtitle language code for the account. + id (int): The Plex account ID. + key (str): API URL (/accounts/) + name (str): The username of the account. + subtitleMode (bool): The subtitle mode for the account. + thumb (str): URL for the account thumbnail. + """ + TAG = 'Account' + + def _loadData(self, data): + self._data = data + self.autoSelectAudio = cast(bool, data.attrib.get('autoSelectAudio')) + self.defaultAudioLanguage = data.attrib.get('defaultAudioLanguage') + self.defaultSubtitleLanguage = data.attrib.get('defaultSubtitleLanguage') + self.id = cast(int, data.attrib.get('id')) + self.key = data.attrib.get('key') self.name = data.attrib.get('name') + self.subtitleMode = cast(int, data.attrib.get('subtitleMode')) + self.thumb = data.attrib.get('thumb') + # For backwards compatibility + self.accountID = self.id + self.accountKey = self.key + + +class SystemDevice(PlexObject): + """ Represents a single system device. + + Attributes: + TAG (str): 'Device' + createdAt (datatime): Datetime the device was created. + id (int): The ID of the device (not the same as :class:`~plexapi.myplex.MyPlexDevice` ID). + key (str): API URL (/devices/) + name (str): The name of the device. + platform (str): OS the device is running (Linux, Windows, Chrome, etc.) + """ + TAG = 'Device' + + def _loadData(self, data): + self._data = data + self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) + self.id = cast(int, data.attrib.get('id')) + self.key = '/devices/%s' % self.id + self.name = data.attrib.get('name') + self.platform = data.attrib.get('platform') + + +class StatisticsBandwidth(PlexObject): + """ Represents a single statistics bandwidth data. + + Attributes: + TAG (str): 'StatisticsBandwidth' + accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID. + at (datatime): Datetime of the bandwidth data. + bytes (int): The total number of bytes for the specified timespan. + deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID. + lan (bool): True or False wheter the bandwidth is local or remote. + timespan (int): The timespan for the bandwidth data. + 1: months, 2: weeks, 3: days, 4: hours, 6: seconds. + + """ + TAG = 'StatisticsBandwidth' + + def _loadData(self, data): + self._data = data + self.accountID = cast(int, data.attrib.get('accountID')) + self.at = utils.toDatetime(data.attrib.get('at')) + self.bytes = cast(int, data.attrib.get('bytes')) + self.deviceID = cast(int, data.attrib.get('deviceID')) + self.lan = cast(bool, data.attrib.get('lan')) + self.timespan = cast(int, data.attrib.get('timespan')) + + def __repr__(self): + return '<%s>' % ':'.join([p for p in [ + self.__class__.__name__, + self._clean(self.accountID), + self._clean(self.deviceID), + self._clean(int(self.at.timestamp())) + ] if p]) + + def account(self): + """ Returns the :class:`~plexapi.server.SystemAccount` associated with the bandwidth data. """ + accounts = self._server.systemAccounts() + try: + return next(account for account in accounts if account.id == self.accountID) + except StopIteration: + raise NotFound('Unknown account for this bandwidth data: accountID=%s' % self.accountID) + + def device(self): + """ Returns the :class:`~plexapi.server.SystemDevice` associated with the bandwidth data. """ + devices = self._server.systemDevices() + try: + return next(device for device in devices if device.id == self.deviceID) + except StopIteration: + raise NotFound('Unknown device for this bandwidth data: deviceID=%s' % self.deviceID) + + +class StatisticsResources(PlexObject): + """ Represents a single statistics resources data. + + Attributes: + TAG (str): 'StatisticsResources' + at (datatime): Datetime of the resource data. + hostCpuUtilization (float): The system CPU usage %. + hostMemoryUtilization (float): The Plex Media Server CPU usage %. + processCpuUtilization (float): The system RAM usage %. + processMemoryUtilization (float): The Plex Media Server RAM usage %. + timespan (int): The timespan for the resource data (6: seconds). + """ + TAG = 'StatisticsResources' + + def _loadData(self, data): + self._data = data + self.at = utils.toDatetime(data.attrib.get('at')) + self.hostCpuUtilization = cast(float, data.attrib.get('hostCpuUtilization')) + self.hostMemoryUtilization = cast(float, data.attrib.get('hostMemoryUtilization')) + self.processCpuUtilization = cast(float, data.attrib.get('processCpuUtilization')) + self.processMemoryUtilization = cast(float, data.attrib.get('processMemoryUtilization')) + self.timespan = cast(int, data.attrib.get('timespan')) + + def __repr__(self): + return '<%s>' % ':'.join([p for p in [ + self.__class__.__name__, + self._clean(int(self.at.timestamp())) + ] if p]) diff --git a/lib/plexapi/settings.py b/lib/plexapi/settings.py index dddaab90..8416f871 100644 --- a/lib/plexapi/settings.py +++ b/lib/plexapi/settings.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- from collections import defaultdict +from urllib.parse import quote from plexapi import log, utils from plexapi.base import PlexObject -from plexapi.compat import quote, string_type from plexapi.exceptions import BadRequest, NotFound @@ -104,12 +104,11 @@ class Setting(PlexObject): """ _bool_cast = lambda x: True if x == 'true' or x == '1' else False _bool_str = lambda x: str(x).lower() - _str = lambda x: str(x).encode('utf-8') TYPES = { 'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str}, - 'double': {'type': float, 'cast': float, 'tostr': _str}, - 'int': {'type': int, 'cast': int, 'tostr': _str}, - 'text': {'type': string_type, 'cast': _str, 'tostr': _str}, + 'double': {'type': float, 'cast': float, 'tostr': str}, + 'int': {'type': int, 'cast': int, 'tostr': str}, + 'text': {'type': str, 'cast': str, 'tostr': str}, } def _loadData(self, data): @@ -158,3 +157,21 @@ class Setting(PlexObject): def toUrl(self): """Helper for urls""" return '%s=%s' % (self.id, self._value or self.value) + + +@utils.registerPlexObject +class Preferences(Setting): + """ Represents a single Preferences. + + Attributes: + TAG (str): 'Preferences' + FILTER (str): 'preferences' + """ + TAG = 'Preferences' + FILTER = 'preferences' + + def _default(self): + """ Set the default value for this setting.""" + key = '%s/prefs?' % self._initpath + url = key + '%s=%s' % (self.id, self.default) + self._server.query(url, method=self._server._session.put) diff --git a/lib/plexapi/sync.py b/lib/plexapi/sync.py index f88fefb2..ce60ca9f 100644 --- a/lib/plexapi/sync.py +++ b/lib/plexapi/sync.py @@ -201,7 +201,7 @@ class MediaSettings(object): videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in this module. Raises: - :exc:`plexapi.exceptions.BadRequest`: when provided unknown video quality. + :exc:`~plexapi.exceptions.BadRequest`: When provided unknown video quality. """ if videoQuality == VIDEO_QUALITY_ORIGINAL: return MediaSettings('', '', '') @@ -231,7 +231,7 @@ class MediaSettings(object): module. Raises: - :exc:`plexapi.exceptions.BadRequest` when provided unknown video quality. + :exc:`~plexapi.exceptions.BadRequest`: When provided unknown video quality. """ if resolution in PHOTO_QUALITIES: return MediaSettings(photoQuality=PHOTO_QUALITIES[resolution], photoResolution=resolution) diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index 9636b4be..8579ca6c 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -1,17 +1,19 @@ # -*- coding: utf-8 -*- import base64 +import functools import logging import os import re import time +import warnings import zipfile from datetime import datetime from getpass import getpass from threading import Event, Thread +from urllib.parse import quote import requests -from plexapi import compat -from plexapi.exceptions import NotFound +from plexapi.exceptions import BadRequest, NotFound try: from tqdm import tqdm @@ -19,13 +21,13 @@ except ImportError: tqdm = None log = logging.getLogger('plexapi') +warnings.simplefilter('default', category=DeprecationWarning) # 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, 'trailer': 5, 'comic': 6, 'person': 7, 'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14, - 'playlist': 15, 'playlistFolder': 16, 'collection': 18, - 'optimizedVersion': 42, 'userPlaylistItem': 1001} + 'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'optimizedVersion': 42, 'userPlaylistItem': 1001} PLEXOBJECTS = {} @@ -43,7 +45,7 @@ class SecretsFilter(logging.Filter): def filter(self, record): cleanargs = list(record.args) for i in range(len(cleanargs)): - if isinstance(cleanargs[i], compat.string_type): + if isinstance(cleanargs[i], str): for secret in self.secrets: cleanargs[i] = cleanargs[i].replace(secret, '') record.args = tuple(cleanargs) @@ -55,7 +57,7 @@ def registerPlexObject(cls): define a few helper functions to dynamically convery the XML into objects. See buildItem() below for an example. """ - etype = getattr(cls, 'STREAMTYPE', cls.TYPE) + etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', 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' % @@ -101,8 +103,8 @@ def joinArgs(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, safe=''))) + value = str(args[key]) + arglist.append('%s=%s' % (key, quote(value, safe=''))) return '?%s' % '&'.join(arglist) @@ -112,7 +114,7 @@ def lowerFirst(s): 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 + dicts, lists, tuples, functions, classes, etc. The lookup is done recursively for each key in attrstr (split by by the delimiter) This function is heavily influenced by the lookups used in Django templates. @@ -148,10 +150,10 @@ def searchType(libtype): libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track, collection) Raises: - :exc:`plexapi.exceptions.NotFound`: Unknown libtype + :exc:`~plexapi.exceptions.NotFound`: Unknown libtype """ - libtype = compat.ustr(libtype) - if libtype in [compat.ustr(v) for v in SEARCHTYPES.values()]: + libtype = str(libtype) + if libtype in [str(v) for v in SEARCHTYPES.values()]: return libtype if SEARCHTYPES.get(libtype) is not None: return SEARCHTYPES[libtype] @@ -159,12 +161,12 @@ def searchType(libtype): def threaded(callback, listargs): - """ Returns the result of for each set of \*args in listargs. Each call + """ Returns the result of for each set of `*args` in listargs. Each call to is called concurrently in their own separate threads. Parameters: - callback (func): Callback function to apply to each set of \*args. - listargs (list): List of lists; \*args to pass each thread. + callback (func): Callback function to apply to each set of `*args`. + listargs (list): List of lists; `*args` to pass each thread. """ threads, results = [], [] job_is_done_event = Event() @@ -206,6 +208,19 @@ def toDatetime(value, format=None): return value +def millisecondToHumanstr(milliseconds): + """ Returns human readable time duration from milliseconds. + HH:MM:SS:MMMM + + Parameters: + milliseconds (str,int): time duration in milliseconds. + """ + milliseconds = int(milliseconds) + r = datetime.utcfromtimestamp(milliseconds / 1000) + f = r.strftime("%H:%M:%S.%f") + return f[:-2] + + def toList(value, itemcast=None, delim=','): """ Returns a list of strings from the specified value. @@ -277,7 +292,7 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4 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) + os.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'): @@ -356,6 +371,10 @@ def getMyPlexAccount(opts=None): # pragma: no cover if config_username and config_password: print('Authenticating with Plex.tv as %s..' % config_username) return MyPlexAccount(config_username, config_password) + config_token = CONFIG.get('auth.server_token') + if config_token: + print('Authenticating with Plex.tv with token') + return MyPlexAccount(token=config_token) # 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: ') @@ -363,6 +382,30 @@ def getMyPlexAccount(opts=None): # pragma: no cover return MyPlexAccount(username, password) +def createMyPlexDevice(headers, account, timeout=10): # pragma: no cover + """ Helper function to create a new MyPlexDevice. + + Parameters: + headers (dict): Provide the X-Plex- headers for the new device. + A unique X-Plex-Client-Identifier is required. + account (MyPlexAccount): The Plex account to create the device on. + timeout (int): Timeout in seconds to wait for device login. + """ + from plexapi.myplex import MyPlexPinLogin + + if 'X-Plex-Client-Identifier' not in headers: + raise BadRequest('The X-Plex-Client-Identifier header is required.') + + clientIdentifier = headers['X-Plex-Client-Identifier'] + + pinlogin = MyPlexPinLogin(headers=headers) + pinlogin.run(timeout=timeout) + account.link(pinlogin.pin) + pinlogin.waitForLogin() + + return account.device(clientId=clientIdentifier) + + 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. @@ -404,3 +447,18 @@ def getAgentIdentifier(section, agent): def base64str(text): return base64.b64encode(text.encode('utf-8')).decode('utf-8') + + +def deprecated(message): + def decorator(func): + """This is a decorator which can be used to mark functions + as deprecated. It will result in a warning being emitted + when the function is used.""" + @functools.wraps(func) + def wrapper(*args, **kwargs): + msg = 'Call to deprecated function or method "%s", %s.' % (func.__name__, message) + warnings.warn(msg, category=DeprecationWarning, stacklevel=3) + log.warning(msg) + return func(*args, **kwargs) + return wrapper + return decorator diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 68e1e312..508dc5a4 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -1,47 +1,54 @@ # -*- coding: utf-8 -*- -from plexapi import media, utils -from plexapi.exceptions import BadRequest, NotFound -from plexapi.base import Playable, PlexPartialObject -from plexapi.compat import quote_plus, urlencode import os +from urllib.parse import quote_plus, urlencode + +from plexapi import library, media, settings, utils +from plexapi.base import Playable, PlexPartialObject +from plexapi.exceptions import BadRequest, NotFound 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`. + :class:`~plexapi.video.Episode`, and :class:`~plexapi.video.Clip`. Attributes: - addedAt (datetime): Datetime this item was added to the library. - art (str): URL to artwork image. + addedAt (datetime): Datetime the item was added to the library. + art (str): URL to artwork image (/library/metadata//art/). artBlurHash (str): BlurHash string for artwork image. + fields (List<:class:`~plexapi.media.Field`>): List of field objects. + guid (str): Plex GUID for the movie, show, season, episode, or clip (plex://movie/5d776b59ad5437001f79c6f8). key (str): API URL (/library/metadata/). - lastViewedAt (datetime): Datetime item was last accessed. + lastViewedAt (datetime): Datetime the item was last played. 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. + librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + listType (str): Hardcoded as 'video' (useful for search filters). + ratingKey (int): Unique key identifying the item. + summary (str): Summary of the movie, show, season, episode, or clip. + thumb (str): URL to thumbnail image (/library/metadata//thumb/). thumbBlurHash (str): BlurHash string for thumbnail image. - title (str): Artist, Album or Track title. (Jason Mraz, We Sing, Lucky, etc.) + title (str): Name of the movie, show, season, episode, or clip. 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. + type (str): 'movie', 'show', 'season', 'episode', or 'clip'. + updatedAt (datatime): Datetime the item was updated. + viewCount (int): Count of times the item was played. """ 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.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') + self.fields = self.findItems(data, media.Field) + self.guid = data.attrib.get('guid') self.key = data.attrib.get('key', '') self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) self.librarySectionID = data.attrib.get('librarySectionID') self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.listType = 'video' self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.summary = data.attrib.get('summary') self.thumb = data.attrib.get('thumb') @@ -133,8 +140,9 @@ class Video(PlexPartialObject): policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None): """ Optimize item - locationID (int): -1 in folder with orginal items - 2 library path + locationID (int): -1 in folder with original items + 2 library path id + library path id is found in library.locations[i].id target (str): custom quality name. if none provided use "Custom: {deviceProfile}" @@ -164,6 +172,13 @@ class Video(PlexPartialObject): if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None): raise BadRequest('Unexpected or missing quality profile.') + libraryLocationIDs = [location.id for location in self.section()._locations()] + libraryLocationIDs.append(-1) + + if locationID not in libraryLocationIDs: + raise BadRequest('Unexpected library path ID. %s not in %s' % + (locationID, libraryLocationIDs)) + if isinstance(targetTagID, str): tagIndex = tagKeys.index(targetTagID) targetTagID = tagValues[tagIndex] @@ -250,35 +265,33 @@ class Movie(Playable, Video): 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) + audienceRatingImage (str): Key to audience rating image (rottentomatoes://image.rating.spilled). + chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects. chapterSource (str): Chapter source (agent; media; mixed). + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. 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). + countries (List<:class:`~plexapi.media.Country`>): List of countries objects. + directors (List<:class:`~plexapi.media.Director`>): List of director objects. + duration (int): Duration of the movie in milliseconds. + genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originallyAvailableAt (datetime): Datetime the movie was released. 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). + producers (List<:class:`~plexapi.media.Producer`>): List of producers objects. + rating (float): Movie critic rating (7.9; 9.8; 8.1). + ratingImage (str): Key to critic rating image (rottentomatoes://image.rating.rotten). + roles (List<:class:`~plexapi.media.Role`>): List of role objects. + similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. 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. - guids (List<:class:`~plexapi.media.Guid`>): List of guid 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. + year (int): Year movie was released. """ TAG = 'Video' TYPE = 'movie' @@ -288,38 +301,33 @@ class Movie(Playable, Video): """ Load attribute values from Plex XML response. """ Video._loadData(self, data) Playable._loadData(self, data) - - self.art = data.attrib.get('art') self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) self.audienceRatingImage = data.attrib.get('audienceRatingImage') + self.chapters = self.findItems(data, media.Chapter) self.chapterSource = data.attrib.get('chapterSource') + self.collections = self.findItems(data, media.Collection) self.contentRating = data.attrib.get('contentRating') + self.countries = self.findItems(data, media.Country) + self.directors = self.findItems(data, media.Director) self.duration = utils.cast(int, data.attrib.get('duration')) - self.guid = data.attrib.get('guid') + self.genres = self.findItems(data, media.Genre) + self.guids = self.findItems(data, media.Guid) + self.labels = self.findItems(data, media.Label) + self.media = self.findItems(data, media.Media) + self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.originalTitle = data.attrib.get('originalTitle') - self.originallyAvailableAt = utils.toDatetime( - data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.primaryExtraKey = data.attrib.get('primaryExtraKey') + self.producers = self.findItems(data, media.Producer) self.rating = utils.cast(float, data.attrib.get('rating')) self.ratingImage = data.attrib.get('ratingImage') + self.roles = self.findItems(data, media.Role) + self.similar = self.findItems(data, media.Similar) 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.guids = self.findItems(data, media.Guid) - 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) + self.year = utils.cast(int, data.attrib.get('year')) @property def actors(self): @@ -329,7 +337,10 @@ class Movie(Playable, Video): @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 + interface to get the locations of the movie. + + Retruns: + List of file paths where the movie is found on disk. """ return [part.file for part in self.iterParts() if part] @@ -337,6 +348,15 @@ class Movie(Playable, Video): # This is just for compat. return self.title + def hubs(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. """ + data = self._server.query(self._details_key) + video = data.find('Video') + if video: + related = video.find('Related') + if related: + return self.findItems(related, library.Hub) + def download(self, savepath=None, keep_original_name=False, **kwargs): """ Download video files to specified directory. @@ -371,61 +391,56 @@ class Show(Video): 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. + banner (str): Key to banner artwork (/library/metadata//banner/). + childCount (int): Number of seasons in the show. + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. contentRating (str) Content rating (PG-13; NR; TV-G). - collections (List<:class:`~plexapi.media.Collection`>): List of collections this media belongs. - 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. + duration (int): Typical duration of the show episodes in milliseconds. genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. + index (int): Plex index number for the show. + key (str): API URL (/library/metadata/). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. + leafCount (int): Number of items in the show view. + locations (List): List of folder paths where the show is found on disk. + originallyAvailableAt (datetime): Datetime the show was released. + rating (float): Show rating (7.9; 9.8; 8.1). roles (List<:class:`~plexapi.media.Role`>): List of role objects. similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. + studio (str): Studio that created show (Di Bonaventura Pictures; 21 Laps Entertainment). + theme (str): URL to theme resource (/library/metadata//theme/). + viewedLeafCount (int): Number of items marked as played in the show view. + year (int): Year the show was released. """ TAG = 'Directory' TYPE = 'show' METADATA_TYPE = 'episode' - 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', '') # FIX_BUG_50 - 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.collections = self.findItems(data, media.Collection) + 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.genres = self.findItems(data, media.Genre) + self.index = utils.cast(int, data.attrib.get('index')) + self.key = self.key.replace('/children', '') # FIX_BUG_50 + self.labels = self.findItems(data, media.Label) 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.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.rating = utils.cast(float, data.attrib.get('rating')) + self.roles = self.findItems(data, media.Role) + self.similar = self.findItems(data, media.Similar) 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.fields = self.findItems(data, media.Field) - 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) + + def __iter__(self): + for season in self.seasons(): + yield season @property def actors(self): @@ -434,52 +449,116 @@ class Show(Video): @property def isWatched(self): - """ Returns True if this show is fully watched. """ + """ Returns True if the 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?excludeAllLeaves=1' % self.ratingKey - return self.fetchItems(key, **kwargs) + def preferences(self): + """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ + items = [] + data = self._server.query(self._details_key) + for item in data.iter('Preferences'): + for elem in item: + setting = settings.Preferences(data=elem, server=self._server) + setting._initpath = self.key + items.append(setting) - def season(self, title=None): + return items + + def editAdvanced(self, **kwargs): + """ Edit a show's advanced settings. """ + data = {} + key = '%s/prefs?' % self.key + preferences = {pref.id: list(pref.enumValues.keys()) for pref in self.preferences()} + for settingID, value in kwargs.items(): + enumValues = preferences.get(settingID) + if value in enumValues: + data[settingID] = value + else: + raise NotFound('%s not found in %s' % (value, enumValues)) + url = key + urlencode(data) + self._server.query(url, method=self._server._session.put) + + def defaultAdvanced(self): + """ Edit all of show's advanced settings to default. """ + data = {} + key = '%s/prefs?' % self.key + for preference in self.preferences(): + data[preference.id] = preference.default + url = key + urlencode(data) + self._server.query(url, method=self._server._session.put) + + def hubs(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. """ + data = self._server.query(self._details_key) + directory = data.find('Directory') + if directory: + related = directory.find('Related') + if related: + return self.findItems(related, library.Hub) + + def onDeck(self): + """ Returns show's On Deck :class:`~plexapi.video.Video` object or `None`. + If show is unwatched, return will likely be the first episode. + """ + data = self._server.query(self._details_key) + episode = next(data.iter('OnDeck'), None) + if episode: + return self.findItems(episode)[0] + return None + + def season(self, title=None, season=None): """ Returns the season with the specified title or number. Parameters: - title (str or int): Title or Number of the season to return. + title (str): Title of the season to return. + season (int): Season number (default: None; required if title not specified). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing. """ key = '/library/metadata/%s/children' % self.ratingKey - if isinstance(title, int): - return self.fetchItem(key, etag='Directory', index__iexact=str(title)) - return self.fetchItem(key, etag='Directory', title__iexact=title) + if title is not None and not isinstance(title, int): + return self.fetchItem(key, Season, title__iexact=title) + elif season is not None or isinstance(title, int): + if isinstance(title, int): + index = title + else: + index = season + return self.fetchItem(key, Season, index=index) + raise BadRequest('Missing argument: title or season is required') - 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 seasons(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Season` objects in the show. """ + key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey + return self.fetchItems(key, Season, **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). + season (int): Season number (default: None; required if title not specified). + episode (int): Episode number (default: None; required if title not specified). Raises: - :exc:`plexapi.exceptions.BadRequest`: If season and episode is missing. - :exc:`plexapi.exceptions.NotFound`: If the episode is missing. + :exc:`~plexapi.exceptions.BadRequest`: If title or season and episode parameters are missing. """ - if title: - key = '/library/metadata/%s/allLeaves' % self.ratingKey - return self.fetchItem(key, title__iexact=title) - elif season is not None 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)) + key = '/library/metadata/%s/allLeaves' % self.ratingKey + if title is not None: + return self.fetchItem(key, Episode, title__iexact=title) + elif season is not None and episode is not None: + return self.fetchItem(key, Episode, parentIndex=season, index=episode) raise BadRequest('Missing argument: title or season and episode are required') + def episodes(self, **kwargs): + """ Returns a list of :class:`~plexapi.video.Episode` objects in the show. """ + key = '/library/metadata/%s/allLeaves' % self.ratingKey + return self.fetchItems(key, Episode, **kwargs) + + def get(self, title=None, season=None, episode=None): + """ Alias to :func:`~plexapi.video.Show.episode`. """ + return self.episode(title, season, episode) + def watched(self): """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ return self.episodes(viewCount__gt=0) @@ -488,10 +567,6 @@ class Show(Video): """ 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_original_name=False, **kwargs): """ Download video files to specified directory. @@ -514,31 +589,28 @@ class Season(Video): 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. + key (str): API URL (/library/metadata/). + leafCount (int): Number of items in the season view. + parentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6). + parentIndex (int): Plex index number for the show. + parentKey (str): API URL of the show (/library/metadata/). + parentRatingKey (int): Unique key identifying the show. + parentTheme (str): URL to show theme resource (/library/metadata//theme/). + parentThumb (str): URL to show thumbnail image (/library/metadata//thumb/). + parentTitle (str): Name of the show for the season. + viewedLeafCount (int): Number of items marked as played in the season view. """ TAG = 'Directory' TYPE = 'season' METADATA_TYPE = 'episode' - 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', '') - art = data.attrib.get('art') - self.art = art if art and str(self.ratingKey) in art else None - self.guid = data.attrib.get('guid') - self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.index = utils.cast(int, data.attrib.get('index')) + self.key = self.key.replace('/children', '') # FIX_BUG_50 + self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.parentGuid = data.attrib.get('parentGuid') self.parentIndex = data.attrib.get('parentIndex') self.parentKey = data.attrib.get('parentKey') @@ -547,7 +619,10 @@ class Season(Video): self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) - self.fields = self.findItems(data, media.Field) + + def __iter__(self): + for episode in self.episodes(): + yield episode def __repr__(self): return '<%s>' % ':'.join([p for p in [ @@ -558,7 +633,7 @@ class Season(Video): @property def isWatched(self): - """ Returns True if this season is fully watched. """ + """ Returns True if the season is fully watched. """ return bool(self.viewedLeafCount == self.leafCount) @property @@ -567,31 +642,44 @@ class Season(Video): return self.index def episodes(self, **kwargs): - """ Returns a list of :class:`~plexapi.video.Episode` objects. """ + """ Returns a list of :class:`~plexapi.video.Episode` objects in the season. """ key = '/library/metadata/%s/children' % self.ratingKey - return self.fetchItems(key, **kwargs) + return self.fetchItems(key, Episode, **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). + episode (int): Episode number (default: None; required if title not specified). + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing. """ - 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, parentIndex=self.index, index=episode) + if title is not None: + return self.fetchItem(key, Episode, title__iexact=title) + elif episode is not None: + return self.fetchItem(key, Episode, parentIndex=self.index, index=episode) + raise BadRequest('Missing argument: title or episode is required') def get(self, title=None, episode=None): """ Alias to :func:`~plexapi.video.Season.episode`. """ return self.episode(title, episode) + def onDeck(self): + """ Returns season's On Deck :class:`~plexapi.video.Video` object or `None`. + Will only return a match if the show's On Deck episode is in this season. + """ + data = self._server.query(self._details_key) + episode = next(data.iter('OnDeck'), None) + if episode: + return self.findItems(episode)[0] + return None + def show(self): - """ Return this seasons :func:`~plexapi.video.Show`.. """ - return self.fetchItem(int(self.parentRatingKey)) + """ Return the season's :class:`~plexapi.video.Show`. """ + return self.fetchItem(self.parentRatingKey) def watched(self): """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ @@ -627,31 +715,32 @@ class Episode(Playable, Video): Attributes: TAG (str): 'Video' TYPE (str): 'episode' - art (str): Key to episode artwork (/library/metadata//art/) - chapterSource (str): Unknown (media). + chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects. + chapterSource (str): Chapter source (agent; media; mixed). 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. + duration (int): Duration of the episode in milliseconds. + grandparentArt (str): URL to show artwork (/library/metadata//art/). + grandparentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6). + grandparentKey (str): API URL of the show (/library/metadata/). + grandparentRatingKey (int): Unique key identifying the show. + grandparentTheme (str): URL to show theme resource (/library/metadata//theme/). + grandparentThumb (str): URL to show thumbnail image (/library/metadata//thumb/). + grandparentTitle (str): Name of the show for the episode. + index (int): Episode number. + markers (List<:class:`~plexapi.media.Marker`>): List of marker objects. media (List<:class:`~plexapi.media.Media`>): List of media objects. + originallyAvailableAt (datetime): Datetime the episode was released. + parentGuid (str): Plex GUID for the season (plex://season/5d9c09e42df347001e3c2a72). + parentIndex (int): Season number of episode. + parentKey (str): API URL of the season (/library/metadata/). + parentRatingKey (int): Unique key identifying the season. + parentThumb (str): URL to season thumbnail image (/library/metadata//thumb/). + parentTitle (str): Name of the season for the episode. + rating (float): Episode rating (7.9; 9.8; 8.1). + viewOffset (int): View offset in milliseconds. writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. + year (int): Year episode was released. """ TAG = 'Video' TYPE = 'episode' @@ -662,10 +751,10 @@ class Episode(Playable, Video): Video._loadData(self, data) Playable._loadData(self, data) self._seasonNumber = None # cached season number - art = data.attrib.get('art') - self.art = art if art and str(self.ratingKey) in art else None + self.chapters = self.findItems(data, media.Chapter) self.chapterSource = data.attrib.get('chapterSource') self.contentRating = data.attrib.get('contentRating') + self.directors = self.findItems(data, media.Director) self.duration = utils.cast(int, data.attrib.get('duration')) self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentGuid = data.attrib.get('grandparentGuid') @@ -674,27 +763,20 @@ class Episode(Playable, Video): 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.markers = self.findItems(data, media.Marker) + self.media = self.findItems(data, media.Media) self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.parentGuid = data.attrib.get('parentGuid') - self.parentIndex = data.attrib.get('parentIndex') + self.parentIndex = utils.cast(int, 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.fields = self.findItems(data, media.Field) - 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) - self.markers = self.findItems(data, media.Marker) + self.year = utils.cast(int, data.attrib.get('year')) def __repr__(self): return '<%s>' % ':'.join([p for p in [ @@ -710,13 +792,16 @@ class Episode(Playable, Video): @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 Episode + interface to get the locations of the episode. + + Retruns: + List of file paths where the episode is found on disk. """ return [part.file for part in self.iterParts() if part] @property def seasonNumber(self): - """ Returns this episodes season number. """ + """ Returns the 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) @@ -728,18 +813,18 @@ class Episode(Playable, Video): @property def hasIntroMarker(self): - """ Returns True if this episode has an intro marker in the xml. """ + """ Returns True if the episode has an intro marker in the xml. """ if not self.isFullObject(): self.reload() return any(marker.type == 'intro' for marker in self.markers) def season(self): - """" Return this episodes :func:`~plexapi.video.Season`.. """ + """" Return the episode's :class:`~plexapi.video.Season`. """ return self.fetchItem(self.parentKey) def show(self): - """" Return this episodes :func:`~plexapi.video.Show`.. """ - return self.fetchItem(int(self.grandparentRatingKey)) + """" Return the episode's :class:`~plexapi.video.Show`. """ + return self.fetchItem(self.grandparentRatingKey) def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ @@ -748,35 +833,49 @@ class Episode(Playable, Video): @utils.registerPlexObject class Clip(Playable, Video): - """ Represents a single Clip.""" + """Represents a single Clip. + + Attributes: + TAG (str): 'Video' + TYPE (str): 'clip' + duration (int): Duration of the clip in milliseconds. + extraType (int): Unknown. + index (int): Plex index number for the clip. + media (List<:class:`~plexapi.media.Media`>): List of media objects. + originallyAvailableAt (datetime): Datetime the clip was released. + skipDetails (int): Unknown. + subtype (str): Type of clip (trailer, behindTheScenes, sceneOrSample, etc.). + thumbAspectRatio (str): Aspect ratio of the thumbnail image. + viewOffset (int): View offset in milliseconds. + year (int): Year clip was released. + """ TAG = 'Video' TYPE = 'clip' METADATA_TYPE = 'clip' def _loadData(self, data): - """ Load attribute values from Plex XML response. """ + """Load attribute values from Plex XML response.""" Video._loadData(self, data) Playable._loadData(self, data) self._data = data - self.addedAt = data.attrib.get('addedAt') self.duration = utils.cast(int, data.attrib.get('duration')) - self.guid = data.attrib.get('guid') - self.key = data.attrib.get('key') - self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') - self.ratingKey = data.attrib.get('ratingKey') + self.extraType = utils.cast(int, data.attrib.get('extraType')) + self.index = utils.cast(int, data.attrib.get('index')) + self.media = self.findItems(data, media.Media) + self.originallyAvailableAt = data.attrib.get('originallyAvailableAt') self.skipDetails = utils.cast(int, data.attrib.get('skipDetails')) self.subtype = data.attrib.get('subtype') - self.thumb = data.attrib.get('thumb') self.thumbAspectRatio = data.attrib.get('thumbAspectRatio') - self.title = data.attrib.get('title') - self.type = data.attrib.get('type') + 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) @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 Clip + interface to get the locations of the clip. + + Retruns: + List of file paths where the clip is found on disk. """ return [part.file for part in self.iterParts() if part]