diff --git a/lib/plexapi/__init__.py b/lib/plexapi/__init__.py index 33c679f7..49720f6b 100644 --- a/lib/plexapi/__init__.py +++ b/lib/plexapi/__init__.py @@ -15,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH) # PlexAPI Settings PROJECT = 'PlexAPI' -VERSION = '4.5.2' +VERSION = '4.6.1' 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 f95d7179..bb1dee22 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -5,7 +5,7 @@ from plexapi import library, media, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin -from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import RatingMixin, SplitMergeMixin, UnmatchMatchMixin from plexapi.mixins import CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin @@ -21,6 +21,7 @@ class Audio(PlexPartialObject): 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/). + lastRatedAt (datetime): Datetime the item was last rated. lastViewedAt (datetime): Datetime the item was last played. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. @@ -35,7 +36,7 @@ class Audio(PlexPartialObject): titleSort (str): Title to use when sorting (defaults to title). type (str): 'artist', 'album', or 'track'. updatedAt (datatime): Datetime the item was updated. - userRating (float): Rating of the track (0.0 - 10.0) equaling (0 stars - 5 stars). + userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars). viewCount (int): Count of times the item was played. """ @@ -51,6 +52,7 @@ class Audio(PlexPartialObject): self.guid = data.attrib.get('guid') self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key', '') + self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) self.librarySectionKey = data.attrib.get('librarySectionKey') @@ -65,7 +67,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.userRating = utils.cast(float, data.attrib.get('userRating')) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) def url(self, part): @@ -114,7 +116,7 @@ class Audio(PlexPartialObject): @utils.registerPlexObject -class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, +class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin, CollectionMixin, CountryMixin, GenreMixin, MoodMixin, SimilarArtistMixin, StyleMixin): """ Represents a single Artist. @@ -153,11 +155,7 @@ class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, SplitMergeMixi 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) + return self.findItems(data, library.Hub, rtag='Related') def album(self, title): """ Returns the :class:`~plexapi.audio.Album` that matches the specified title. @@ -221,7 +219,7 @@ class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, SplitMergeMixi @utils.registerPlexObject -class Album(Audio, ArtMixin, PosterMixin, UnmatchMatchMixin, +class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin, CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin): """ Represents a single Album. @@ -328,13 +326,15 @@ class Album(Audio, ArtMixin, PosterMixin, UnmatchMatchMixin, @utils.registerPlexObject -class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, MoodMixin): +class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin, + CollectionMixin, MoodMixin): """ Represents a single Track. Attributes: TAG (str): 'Directory' TYPE (str): 'track' chapterSource (str): Unknown + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. 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). @@ -344,7 +344,7 @@ class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, MoodMixin): (/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). + originalTitle (str): The artist for the track. parentGuid (str): Plex GUID for the album (plex://album/5d07cd8e403c640290f180f9). parentIndex (int): Album index. parentKey (str): API URL of the album (/library/metadata/). @@ -364,6 +364,7 @@ class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, MoodMixin): Audio._loadData(self, data) Playable._loadData(self, data) self.chapterSource = data.attrib.get('chapterSource') + self.collections = self.findItems(data, media.Collection) self.duration = utils.cast(int, data.attrib.get('duration')) self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentGuid = data.attrib.get('grandparentGuid') @@ -401,11 +402,16 @@ class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, MoodMixin): """ This does not exist in plex xml response but is added to have a common interface to get the locations of the track. - Retruns: + Returns: List of file paths where the track is found on disk. """ return [part.file for part in self.iterParts() if part] + @property + def trackNumber(self): + """ Returns the track number. """ + return self.index + 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 c917cf28..bb41f11f 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -2,13 +2,14 @@ import re import weakref from urllib.parse import quote_plus, urlencode +from xml.etree import ElementTree from plexapi import log, utils from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported -from plexapi.utils import tag_plural, tag_helper -DONT_RELOAD_FOR_KEYS = {'key', 'session'} -DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'} +USER_DONT_RELOAD_FOR_KEYS = set() +_DONT_RELOAD_FOR_KEYS = {'key', 'session'} +_DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'} OPERATORS = { 'exact': lambda v, q: v == q, 'iexact': lambda v, q: v.lower() == q.lower(), @@ -47,11 +48,12 @@ class PlexObject(object): self._server = server self._data = data self._initpath = initpath or self.key - self._parent = weakref.ref(parent) if parent else None + self._parent = weakref.ref(parent) if parent is not None else None self._details_key = None if data is not None: self._loadData(data) self._details_key = self._buildDetailsKey() + self._autoReload = False def __repr__(self): uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri')) @@ -60,10 +62,12 @@ class PlexObject(object): def __setattr__(self, attr, value): # Don't overwrite session specific attr with [] - if attr in DONT_OVERWRITE_SESSION_KEYS and value == []: + if attr in _DONT_OVERWRITE_SESSION_KEYS and value == []: value = getattr(self, attr, []) - # Don't overwrite an attr with None unless it's a private variable - if value is not None or attr.startswith('_') or attr not in self.__dict__: + + autoReload = self.__dict__.get('_autoReload') + # Don't overwrite an attr with None unless it's a private variable or not auto reload + if value is not None or attr.startswith('_') or attr not in self.__dict__ or not autoReload: self.__dict__[attr] = value def _clean(self, value): @@ -130,6 +134,19 @@ class PlexObject(object): return True return False + def _manuallyLoadXML(self, xml, cls=None): + """ Manually load an XML string as a :class:`~plexapi.base.PlexObject`. + + Parameters: + xml (str): The XML string to load. + cls (:class:`~plexapi.base.PlexObject`): If you know the class of the + items to be fetched, passing this in will help the parser ensure + it only returns those items. By default we convert the xml elements + with the best guess PlexObjects based on tag and type attrs. + """ + elem = ElementTree.fromstring(xml) + return self._buildItemOrNone(elem, cls) + 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 @@ -249,7 +266,7 @@ class PlexObject(object): item.librarySectionID = librarySectionID return items - def findItems(self, data, cls=None, initpath=None, **kwargs): + def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs): """ Load the specified data to find and build all items with the specified tag and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details on how this is used. @@ -259,6 +276,9 @@ class PlexObject(object): kwargs['etag'] = cls.TAG if cls and cls.TYPE and 'type' not in kwargs: kwargs['type'] = cls.TYPE + # rtag to iter on a specific root tag + if rtag: + data = next(data.iter(rtag), []) # loop through all data elements to find matches items = [] for elem in data: @@ -275,9 +295,12 @@ class PlexObject(object): if value is not None: return value - def listAttrs(self, data, attr, **kwargs): + def listAttrs(self, data, attr, rtag=None, **kwargs): """ Return a list of values from matching attribute. """ results = [] + # rtag to iter on a specific root tag + if rtag: + data = next(data.iter(rtag), []) for elem in data: kwargs['%s__exists' % attr] = True if self._checkAttrs(elem, **kwargs): @@ -315,13 +338,19 @@ class PlexObject(object): movie.isFullObject() # Returns True """ + return self._reload(key=key, **kwargs) + + def _reload(self, key=None, _autoReload=False, **kwargs): + """ Perform the actual reload. """ details_key = self._buildDetailsKey(**kwargs) if kwargs else self._details_key key = key or details_key or self.key if not key: raise Unsupported('Cannot reload an object not built from a URL.') self._initpath = key data = self._server.query(key) + self._autoReload = _autoReload self._loadData(data[0]) + self._autoReload = False return self def _checkAttrs(self, elem, **kwargs): @@ -427,8 +456,9 @@ class PlexPartialObject(PlexObject): # Dragons inside.. :-/ value = super(PlexPartialObject, self).__getattribute__(attr) # Check a few cases where we dont want to reload - if attr in DONT_RELOAD_FOR_KEYS: return value - if attr in DONT_OVERWRITE_SESSION_KEYS: return value + if attr in _DONT_RELOAD_FOR_KEYS: return value + if attr in _DONT_OVERWRITE_SESSION_KEYS: return value + if attr in USER_DONT_RELOAD_FOR_KEYS: return value if attr.startswith('_'): return value if value not in (None, []): return value if self.isFullObject(): return value @@ -438,7 +468,7 @@ class PlexPartialObject(PlexObject): objname = "%s '%s'" % (clsname, title) if title else clsname log.debug("Reloading %s for attr '%s'", objname, attr) # Reload and return the value - self.reload() + self._reload(_autoReload=True) return super(PlexPartialObject, self).__getattribute__(attr) def analyze(self): @@ -464,7 +494,7 @@ class PlexPartialObject(PlexObject): self._server.query(key, method=self._server._session.put) def isFullObject(self): - """ Retruns True if this is already a full object. A full object means all attributes + """ Returns True if this is already a full object. A full object means all attributes were populated from the api path representing only this item. For example, the search result for a movie often only contain a portion of the attributes a full object (main url) for that movie would contain. @@ -507,9 +537,9 @@ class PlexPartialObject(PlexObject): """ if not isinstance(items, list): items = [items] - value = getattr(self, tag_plural(tag)) + value = getattr(self, utils.tag_plural(tag)) existing_tags = [t.tag for t in value if t and remove is False] - tag_edits = tag_helper(tag, existing_tags + items, locked, remove) + tag_edits = utils.tag_helper(tag, existing_tags + items, locked, remove) self.edit(**tag_edits) def refresh(self): @@ -594,7 +624,7 @@ class Playable(object): Raises: :exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL. """ - if self.TYPE not in ('movie', 'episode', 'track'): + if self.TYPE not in ('movie', 'episode', 'track', 'clip'): raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE) mvb = params.get('maxVideoBitrate') vr = params.get('videoResolution', '') @@ -680,7 +710,7 @@ class Playable(object): key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey, time, state) self._server.query(key) - self.reload() + self._reload(_autoReload=True) def updateTimeline(self, time, state='stopped', duration=None): """ Set the timeline progress for this video. @@ -698,4 +728,35 @@ class Playable(object): key = '/:/timeline?ratingKey=%s&key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s%s' key %= (self.ratingKey, self.key, time, state, durationStr) self._server.query(key) - self.reload() + self._reload(_autoReload=True) + + +class MediaContainer(PlexObject): + """ Represents a single MediaContainer. + + Attributes: + TAG (str): 'MediaContainer' + allowSync (int): Sync/Download is allowed/disallowed for feature. + augmentationKey (str): API URL (/library/metadata/augmentations/). + identifier (str): "com.plexapp.plugins.library" + librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. + librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. + librarySectionUUID (str): :class:`~plexapi.library.LibrarySection` UUID. + mediaTagPrefix (str): "/system/bundle/media/flags/" + mediaTagVersion (int): Unknown + size (int): The number of items in the hub. + + """ + TAG = 'MediaContainer' + + def _loadData(self, data): + self._data = data + self.allowSync = utils.cast(int, data.attrib.get('allowSync')) + self.augmentationKey = data.attrib.get('augmentationKey') + self.identifier = data.attrib.get('identifier') + self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.librarySectionUUID = data.attrib.get('librarySectionUUID') + self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') + self.mediaTagVersion = data.attrib.get('mediaTagVersion') + self.size = utils.cast(int, data.attrib.get('size')) diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index 86ca7ee5..0eb20924 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -1,15 +1,18 @@ # -*- coding: utf-8 -*- +from urllib.parse import quote_plus + from plexapi import media, utils from plexapi.base import PlexPartialObject -from plexapi.exceptions import BadRequest -from plexapi.mixins import ArtMixin, PosterMixin +from plexapi.exceptions import BadRequest, NotFound, Unsupported +from plexapi.library import LibrarySection +from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin from plexapi.mixins import LabelMixin -from plexapi.settings import Setting +from plexapi.playqueue import PlayQueue from plexapi.utils import deprecated @utils.registerPlexObject -class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): +class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin): """ Represents a single Collection. Attributes: @@ -29,6 +32,7 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): index (int): Plex index number for the collection. key (str): API URL (/library/metadata/). labels (List<:class:`~plexapi.media.Label`>): List of label objects. + lastRatedAt (datetime): Datetime the collection was last rated. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. @@ -45,12 +49,13 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): titleSort (str): Title to use when sorting (defaults to title). type (str): 'collection' updatedAt (datatime): Datetime the collection was updated. + userRating (float): Rating of the collection (0.0 - 10.0) equaling (0 stars - 5 stars). """ - TAG = 'Directory' TYPE = 'collection' def _loadData(self, data): + self._data = data self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') @@ -65,6 +70,7 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): 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.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') @@ -81,83 +87,402 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): 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')) + self._items = None # cache for self.items + self._section = None # cache for self.section + + def __len__(self): # pragma: no cover + return len(self.items()) + + def __iter__(self): # pragma: no cover + for item in self.items(): + yield item + + def __contains__(self, other): # pragma: no cover + return any(i.key == other.key for i in self.items()) + + def __getitem__(self, key): # pragma: no cover + return self.items()[key] + + @property + def listType(self): + """ Returns the listType for the collection. """ + if self.isVideo: + return 'video' + elif self.isAudio: + return 'audio' + elif self.isPhoto: + return 'photo' + else: + raise Unsupported('Unexpected collection type') + + @property + def metadataType(self): + """ Returns the type of metadata in the collection. """ + return self.subtype + + @property + def isVideo(self): + """ Returns True if this is a video collection. """ + return self.subtype in {'movie', 'show', 'season', 'episode'} + + @property + def isAudio(self): + """ Returns True if this is an audio collection. """ + return self.subtype in {'artist', 'album', 'track'} + + @property + def isPhoto(self): + """ Returns True if this is a photo collection. """ + return self.subtype in {'photoalbum', 'photo'} @property @deprecated('use "items" instead', stacklevel=3) def children(self): return self.items() + def section(self): + """ Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to. + """ + if self._section is None: + self._section = super(Collection, self).section() + return self._section + def item(self, title): """ Returns the item in the collection that matches the specified title. Parameters: title (str): Title of the item to return. + + Raises: + :class:`plexapi.exceptions.NotFound`: When the item is not found in the collection. """ - key = '/library/metadata/%s/children' % self.ratingKey - return self.fetchItem(key, title__iexact=title) + for item in self.items(): + if item.title.lower() == title.lower(): + return item + raise NotFound('Item with title "%s" not found in the collection' % title) def items(self): """ Returns a list of all items in the collection. """ - key = '/library/metadata/%s/children' % self.ratingKey - return self.fetchItems(key) + if self._items is None: + key = '%s/children' % self.key + items = self.fetchItems(key) + self._items = items + return self._items 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 modeUpdate(self, mode=None): - """ Update Collection Mode + """ Update the collection mode advanced setting. Parameters: - mode: default (Library default) - hide (Hide Collection) - hideItems (Hide Items in this Collection) - showItems (Show this Collection and its Items) + mode (str): One of the following values: + "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") + .. code-block:: python + + collection.updateMode(mode="hide") """ - mode_dict = {'default': -1, - 'hide': 0, - 'hideItems': 1, - 'showItems': 2} + mode_dict = { + 'default': -1, + 'hide': 0, + 'hideItems': 1, + 'showItems': 2 + } key = mode_dict.get(mode) if key is None: raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict))) - part = '/library/metadata/%s/prefs?collectionMode=%s' % (self.ratingKey, key) - return self._server.query(part, method=self._server._session.put) + self.editAdvanced(collectionMode=key) def sortUpdate(self, sort=None): - """ Update Collection Sorting + """ Update the collection order advanced setting. Parameters: - sort: realease (Order Collection by realease dates) - alpha (Order Collection alphabetically) - custom (Custom collection order) + sort (str): One of the following values: + "realease" (Order Collection by realease dates), + "alpha" (Order Collection alphabetically), + "custom" (Custom collection order) Example: - colleciton = 'plexapi.library.Collections' - collection.updateSort(mode="alpha") + .. code-block:: python + + collection.updateSort(mode="alpha") """ - sort_dict = {'release': 0, - 'alpha': 1, - 'custom': 2} + sort_dict = { + 'release': 0, + 'alpha': 1, + 'custom': 2 + } key = sort_dict.get(sort) if key is None: raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict))) - part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key) - return self._server.query(part, method=self._server._session.put) + self.editAdvanced(collectionSort=key) + + def addItems(self, items): + """ Add items to the collection. + + Parameters: + items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to be added to the collection. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to add items to a smart collection. + """ + if self.smart: + raise BadRequest('Cannot add items to a smart collection.') + + if items and not isinstance(items, (list, tuple)): + items = [items] + + ratingKeys = [] + for item in items: + if item.type != self.subtype: # pragma: no cover + raise BadRequest('Can not mix media types when building a collection: %s and %s' % + (self.subtype, item.type)) + ratingKeys.append(str(item.ratingKey)) + + ratingKeys = ','.join(ratingKeys) + uri = '%s/library/metadata/%s' % (self._server._uriRoot(), ratingKeys) + + key = '%s/items%s' % (self.key, utils.joinArgs({ + 'uri': uri + })) + self._server.query(key, method=self._server._session.put) + + def removeItems(self, items): + """ Remove items from the collection. + + Parameters: + items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to be removed from the collection. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to remove items from a smart collection. + """ + if self.smart: + raise BadRequest('Cannot remove items from a smart collection.') + + if items and not isinstance(items, (list, tuple)): + items = [items] + + for item in items: + key = '%s/items/%s' % (self.key, item.ratingKey) + self._server.query(key, method=self._server._session.delete) + + def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs): + """ Update the filters for a smart collection. + + Parameters: + libtype (str): The specific type of content to filter + (movie, show, season, episode, artist, album, track, photoalbum, photo, collection). + limit (int): Limit the number of items in the collection. + sort (str or list, optional): A string of comma separated sort fields + or a list of sort fields in the format ``column:dir``. + See :func:`~plexapi.library.LibrarySection.search` for more info. + filters (dict): A dictionary of advanced filters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + **kwargs (dict): Additional custom filters to apply to the search results. + See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying update filters for a regular collection. + """ + if not self.smart: + raise BadRequest('Cannot update filters for a regular collection.') + + section = self.section() + searchKey = section._buildSearchKey( + sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs) + uri = '%s%s' % (self._server._uriRoot(), searchKey) + + key = '%s/items%s' % (self.key, utils.joinArgs({ + 'uri': uri + })) + self._server.query(key, method=self._server._session.put) + + def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs): + """ Edit the collection. + + Parameters: + title (str, optional): The title of the collection. + titleSort (str, optional): The sort title of the collection. + contentRating (str, optional): The summary of the collection. + summary (str, optional): The summary of the collection. + """ + args = {} + if title is not None: + args['title.value'] = title + args['title.locked'] = 1 + if titleSort is not None: + args['titleSort.value'] = titleSort + args['titleSort.locked'] = 1 + if contentRating is not None: + args['contentRating.value'] = contentRating + args['contentRating.locked'] = 1 + if summary is not None: + args['summary.value'] = summary + args['summary.locked'] = 1 + + args.update(kwargs) + super(Collection, self).edit(**args) + + def delete(self): + """ Delete the collection. """ + super(Collection, self).delete() + + def playQueue(self, *args, **kwargs): + """ Returns a new :class:`~plexapi.playqueue.PlayQueue` from the collection. """ + return PlayQueue.create(self._server, self.items(), *args, **kwargs) + + @classmethod + def _create(cls, server, title, section, items): + """ Create a regular collection. """ + if not items: + raise BadRequest('Must include items to add when creating new collection.') + + if not isinstance(section, LibrarySection): + section = server.library.section(section) + + if items and not isinstance(items, (list, tuple)): + items = [items] + + itemType = items[0].type + ratingKeys = [] + for item in items: + if item.type != itemType: # pragma: no cover + raise BadRequest('Can not mix media types when building a collection.') + ratingKeys.append(str(item.ratingKey)) + + ratingKeys = ','.join(ratingKeys) + uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys) + + key = '/library/collections%s' % utils.joinArgs({ + 'uri': uri, + 'type': utils.searchType(itemType), + 'title': title, + 'smart': 0, + 'sectionId': section.key + }) + data = server.query(key, method=server._session.post)[0] + return cls(server, data, initpath=key) + + @classmethod + def _createSmart(cls, server, title, section, limit=None, libtype=None, sort=None, filters=None, **kwargs): + """ Create a smart collection. """ + if not isinstance(section, LibrarySection): + section = server.library.section(section) + + libtype = libtype or section.TYPE + + searchKey = section._buildSearchKey( + sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs) + uri = '%s%s' % (server._uriRoot(), searchKey) + + key = '/library/collections%s' % utils.joinArgs({ + 'uri': uri, + 'type': utils.searchType(libtype), + 'title': title, + 'smart': 1, + 'sectionId': section.key + }) + data = server.query(key, method=server._session.post)[0] + return cls(server, data, initpath=key) + + @classmethod + def create(cls, server, title, section, items=None, smart=False, limit=None, + libtype=None, sort=None, filters=None, **kwargs): + """ Create a collection. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): Server to create the collection on. + title (str): Title of the collection. + section (:class:`~plexapi.library.LibrarySection`, str): The library section to create the collection in. + items (List): Regular collections only, list of :class:`~plexapi.audio.Audio`, + :class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the collection. + smart (bool): True to create a smart collection. Default False. + limit (int): Smart collections only, limit the number of items in the collection. + libtype (str): Smart collections only, the specific type of content to filter + (movie, show, season, episode, artist, album, track, photoalbum, photo, collection). + sort (str or list, optional): Smart collections only, a string of comma separated sort fields + or a list of sort fields in the format ``column:dir``. + See :func:`~plexapi.library.LibrarySection.search` for more info. + filters (dict): Smart collections only, a dictionary of advanced filters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + **kwargs (dict): Smart collections only, additional custom filters to apply to the + search results. See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When no items are included to create the collection. + :class:`plexapi.exceptions.BadRequest`: When mixing media types in the collection. + + Returns: + :class:`~plexapi.collection.Collection`: A new instance of the created Collection. + """ + if smart: + return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs) + else: + return cls._create(server, title, section, items) + + def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None, + unwatched=False, title=None): + """ Add the collection as sync item for the specified device. + See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. + + Parameters: + videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in + :mod:`~plexapi.sync` module. Used only when collection contains video. + photoResolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in + the module :mod:`~plexapi.sync`. Used only when collection contains photos. + audioBitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values + from the module :mod:`~plexapi.sync`. Used only when collection contains audio. + client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see + :func:`~plexapi.myplex.MyPlexAccount.sync`. + clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`. + limit (int): maximum count of items to sync, unlimited if `None`. + unwatched (bool): if `True` watched videos wouldn't be synced. + title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be + generated from metadata of current photo. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When collection is not allowed to sync. + :exc:`~plexapi.exceptions.Unsupported`: When collection content is unsupported. + + Returns: + :class:`~plexapi.sync.SyncItem`: A new instance of the created sync item. + """ + if not self.section().allowSync: + raise BadRequest('The collection is not allowed to sync') + + from plexapi.sync import SyncItem, Policy, MediaSettings + + myplex = self._server.myPlexAccount() + sync_item = SyncItem(self._server, None) + sync_item.title = title if title else self.title + sync_item.rootTitle = self.title + sync_item.contentType = self.listType + sync_item.metadataType = self.metadataType + sync_item.machineIdentifier = self._server.machineIdentifier + + sync_item.location = 'library:///directory/%s' % quote_plus( + '%s/children?excludeAllLeaves=1' % (self.key) + ) + sync_item.policy = Policy.create(limit, unwatched) + + if self.isVideo: + sync_item.mediaSettings = MediaSettings.createVideo(videoQuality) + elif self.isAudio: + sync_item.mediaSettings = MediaSettings.createMusic(audioBitrate) + elif self.isPhoto: + sync_item.mediaSettings = MediaSettings.createPhoto(photoResolution) + else: + raise Unsupported('Unsupported collection content') + + return myplex.sync(sync_item, client=client, clientId=clientId) diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index 99323b40..006fda70 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -219,7 +219,7 @@ class Library(PlexObject): **Show Preferences** * **agent** (str): com.plexapp.agents.none, com.plexapp.agents.thetvdb, com.plexapp.agents.themoviedb, - tv.plex.agent.series + tv.plex.agents.series * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true. * **episodeSort** (int): Episode order. Default -1 Possible options: 0:Oldest first, 1:Newest first. * **flattenSeasons** (int): Seasons. Default value 0 Possible options: 0:Show,1:Hide. @@ -504,11 +504,10 @@ class LibrarySection(PlexObject): for settingID, value in kwargs.items(): try: - enums = idEnums.get(settingID) - enumValues = [int(x) for x in enums] - except TypeError: + enums = idEnums[settingID] + except KeyError: raise NotFound('%s not found in %s' % (value, list(idEnums.keys()))) - if value in enumValues: + if value in enums: data[key % settingID] = value else: raise NotFound('%s not found in %s' % (value, enums)) @@ -538,13 +537,16 @@ class LibrarySection(PlexObject): key = '/library/sections/%s/onDeck' % self.key return self.fetchItems(key) - def recentlyAdded(self, maxresults=50): + def recentlyAdded(self, maxresults=50, libtype=None): """ Returns a list of media items recently added from this library section. Parameters: maxresults (int): Max number of items to return (default 50). + libtype (str, optional): The library type to filter (movie, show, season, episode, + artist, album, track, photoalbum, photo). Default is the main library type. """ - return self.search(sort='addedAt:desc', maxresults=maxresults) + libtype = libtype or self.TYPE + return self.search(sort='addedAt:desc', maxresults=maxresults, libtype=libtype) def firstCharacter(self): key = '/library/sections/%s/firstCharacter' % self.key @@ -596,12 +598,18 @@ class LibrarySection(PlexObject): """ Retrieves and caches the list of :class:`~plexapi.library.FilteringType` and list of :class:`~plexapi.library.FilteringFieldType` for this library section. """ - key = '/library/sections/%s/all?includeMeta=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0' % self.key + _key = ('/library/sections/%s/%s?includeMeta=1&includeAdvanced=1' + '&X-Plex-Container-Start=0&X-Plex-Container-Size=0') + + key = _key % (self.key, 'all') data = self._server.query(key) - meta = data.find('Meta') - if meta: - self._filterTypes = self.findItems(meta, FilteringType) - self._fieldTypes = self.findItems(meta, FilteringFieldType) + self._filterTypes = self.findItems(data, FilteringType, rtag='Meta') + self._fieldTypes = self.findItems(data, FilteringFieldType, rtag='Meta') + + if self.TYPE != 'photo': # No collections for photo library + key = _key % (self.key, 'collections') + data = self._server.query(key) + self._filterTypes.extend(self.findItems(data, FilteringType, rtag='Meta')) def filterTypes(self): """ Returns a list of available :class:`~plexapi.library.FilteringType` for this library section. """ @@ -614,7 +622,7 @@ class LibrarySection(PlexObject): Parameters: libtype (str, optional): The library type to filter (movie, show, season, episode, - artist, album, track, photoalbum, photo). + artist, album, track, photoalbum, photo, collection). Raises: :exc:`~plexapi.exceptions.NotFound`: Unknown libtype for this library. @@ -659,7 +667,7 @@ class LibrarySection(PlexObject): Parameters: libtype (str, optional): The library type to filter (movie, show, season, episode, - artist, album, track, photoalbum, photo). + artist, album, track, photoalbum, photo, collection). Example: @@ -678,7 +686,7 @@ class LibrarySection(PlexObject): Parameters: libtype (str, optional): The library type to filter (movie, show, season, episode, - artist, album, track, photoalbum, photo). + artist, album, track, photoalbum, photo, collection). Example: @@ -697,7 +705,7 @@ class LibrarySection(PlexObject): Parameters: libtype (str, optional): The library type to filter (movie, show, season, episode, - artist, album, track, photoalbum, photo). + artist, album, track, photoalbum, photo, collection). Example: @@ -740,7 +748,7 @@ class LibrarySection(PlexObject): field (str): :class:`~plexapi.library.FilteringFilter` object, or the name of the field (genre, year, contentRating, etc.). libtype (str, optional): The library type to filter (movie, show, season, episode, - artist, album, track, photoalbum, photo). + artist, album, track, photoalbum, photo, collection). Raises: :exc:`~plexapi.exceptions.BadRequest`: Invalid filter field. @@ -783,11 +791,11 @@ class LibrarySection(PlexObject): libtype = _libtype or libtype or self.TYPE try: - filterField = next(f for f in self.listFields(libtype) if f.key.endswith(field)) + filterField = next(f for f in self.listFields(libtype) if f.key.split('.')[-1] == field) except StopIteration: for filterType in reversed(self.filterTypes()): if filterType.type != libtype: - filterField = next((f for f in filterType.fields if f.key.endswith(field)), None) + filterField = next((f for f in filterType.fields if f.key.split('.')[-1] == field), None) if filterField: break else: @@ -854,7 +862,7 @@ class LibrarySection(PlexObject): elif fieldType.type == 'date': value = self._validateFieldValueDate(value) elif fieldType.type == 'integer': - value = int(value) + value = float(value) if '.' in str(value) else int(value) elif fieldType.type == 'string': value = str(value) elif fieldType.type in choiceTypes: @@ -880,6 +888,19 @@ class LibrarySection(PlexObject): else: return int(utils.toDatetime(value, '%Y-%m-%d').timestamp()) + def _validateSortFields(self, sort, libtype=None): + """ Validates a list of filter sort fields is available for the library. + Returns the validated comma separated sort fields string. + """ + if isinstance(sort, str): + sort = sort.split(',') + + validatedSorts = [] + for _sort in sort: + validatedSorts.append(self._validateSortField(_sort.strip(), libtype)) + + return ','.join(validatedSorts) + def _validateSortField(self, sort, libtype=None): """ Validates a filter sort field is available for the library. Returns the validated sort field string. @@ -891,19 +912,19 @@ class LibrarySection(PlexObject): libtype = _libtype or libtype or self.TYPE try: - filterSort = next(f for f in self.listSorts(libtype) if f.key.endswith(sortField)) + filterSort = next(f for f in self.listSorts(libtype) if f.key == sortField) except StopIteration: availableSorts = [f.key for f in self.listSorts(libtype)] raise NotFound('Unknown sort field "%s" for libtype "%s". ' 'Available sort fields: %s' % (sortField, libtype, availableSorts)) from None - sortField = filterSort.key + sortField = libtype + '.' + filterSort.key if not sortDir: sortDir = filterSort.defaultDirection - availableDirections = ['asc', 'desc'] + availableDirections = ['asc', 'desc', 'nullsLast'] if sortDir not in availableDirections: raise NotFound('Unknown sort direction "%s". ' 'Available sort directions: %s' @@ -911,28 +932,94 @@ class LibrarySection(PlexObject): return '%s:%s' % (sortField, sortDir) + def _validateAdvancedSearch(self, filters, libtype): + """ Validates an advanced search filter dictionary. + Returns the list of validated URL encoded parameter strings for the advanced search. + """ + if not isinstance(filters, dict): + raise BadRequest('Filters must be a dictionary.') + + validatedFilters = [] + + for field, values in filters.items(): + if field.lower() in {'and', 'or'}: + if len(filters.items()) > 1: + raise BadRequest('Multiple keys in the same dictionary with and/or is not allowed.') + if not isinstance(values, list): + raise BadRequest('Value for and/or keys must be a list of dictionaries.') + + validatedFilters.append('push=1') + + for value in values: + validatedFilters.extend(self._validateAdvancedSearch(value, libtype)) + validatedFilters.append('%s=1' % field.lower()) + + del validatedFilters[-1] + validatedFilters.append('pop=1') + + else: + validatedFilters.append(self._validateFilterField(field, values, libtype)) + + return validatedFilters + + def _buildSearchKey(self, title=None, sort=None, libtype=None, limit=None, filters=None, returnKwargs=False, **kwargs): + """ Returns the validated and formatted search query API key + (``/library/sections//all?``). + """ + args = {} + filter_args = [] + for field, values in list(kwargs.items()): + if field.split('__')[-1] not in OPERATORS: + filter_args.append(self._validateFilterField(field, values, libtype)) + del kwargs[field] + if title is not None: + if isinstance(title, (list, tuple)): + filter_args.append(self._validateFilterField('title', title, libtype)) + else: + args['title'] = title + if filters is not None: + filter_args.extend(self._validateAdvancedSearch(filters, libtype)) + if sort is not None: + args['sort'] = self._validateSortFields(sort, libtype) + if libtype is not None: + args['type'] = utils.searchType(libtype) + if limit is not None: + args['limit'] = limit + + joined_args = utils.joinArgs(args).lstrip('?') + joined_filter_args = '&'.join(filter_args) if filter_args else '' + params = '&'.join([joined_args, joined_filter_args]).strip('&') + key = '/library/sections/%s/all?%s' % (self.key, params) + + if returnKwargs: + return key, kwargs + return key + def hubSearch(self, query, mediatype=None, limit=None): """ Returns the hub search results for this library. See :func:`plexapi.server.PlexServer.search` for details and parameters. """ return self._server.search(query, mediatype, limit, sectionId=self.key) - def search(self, title=None, sort=None, maxresults=None, - libtype=None, container_start=0, container_size=X_PLEX_CONTAINER_SIZE, **kwargs): + def search(self, title=None, sort=None, maxresults=None, libtype=None, + container_start=0, container_size=X_PLEX_CONTAINER_SIZE, limit=None, filters=None, **kwargs): """ Search the library. The http requests will be batched in container_size. If you are only looking for the first results, it would be wise to set the maxresults option to that amount so the search doesn't iterate over all results on the server. Parameters: title (str, optional): General string query to search for. Partial string matches are allowed. - sort (str, optional): The sort field in the format ``column:dir``. + sort (str or list, optional): A string of comma separated sort fields or a list of sort fields + in the format ``column:dir``. See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields. maxresults (int, optional): Only return the specified number of results. libtype (str, optional): Return results of a specific type (movie, show, season, episode, - artist, album, track, photoalbum, photo) (e.g. ``libtype='episode'`` will only return - :class:`~plexapi.video.Episode` objects) + artist, album, track, photoalbum, photo, collection) (e.g. ``libtype='episode'`` will only + return :class:`~plexapi.video.Episode` objects) container_start (int, optional): Default 0. container_size (int, optional): Default X_PLEX_CONTAINER_SIZE in your config file. + limit (int, optional): Limit the number of results from the filter. + filters (dict, optional): A dictionary of advanced filters. See the details below for more info. **kwargs (dict): Additional custom filters to apply to the search results. See the details below for more info. @@ -1016,22 +1103,22 @@ class LibrarySection(PlexObject): In addition, if the filter does not exist for the default library type it will fallback to the most specific ``libtype`` available. For example, ``show.unwatched`` does not exists so it will fallback to ``episode.unwatched``. The ``libtype`` prefix cannot be included directly in the function parameters so - the ``**kwargs`` must be provided as a dictionary. + the filters must be provided as a filters dictionary. Examples: .. code-block:: python - library.search(**{"show.collection": "Documentary", "episode.inProgress": True}) - library.search(**{"artist.genre": "pop", "album.decade": 2000}) + library.search(filters={"show.collection": "Documentary", "episode.inProgress": True}) + library.search(filters={"artist.genre": "pop", "album.decade": 2000}) # The following three options are identical and will return Episode objects showLibrary.search(title="Winter is Coming", libtype='episode') - showLibrary.search(libtype='episode', **{"episode.title": "Winter is Coming"}) + showLibrary.search(libtype='episode', filters={"episode.title": "Winter is Coming"}) showLibrary.searchEpisodes(title="Winter is Coming") # The following will search for the episode title but return Show objects - showLibrary.search(**{"episode.title": "Winter is Coming"}) + showLibrary.search(filters={"episode.title": "Winter is Coming"}) # The following will fallback to episode.unwatched showLibrary.search(unwatched=True) @@ -1078,27 +1165,55 @@ class LibrarySection(PlexObject): * ``=``: ``is`` - Operators cannot be included directly in the function parameters so the ``**kwargs`` - must be provided as a dictionary. The trailing ``=`` on the operator may be excluded. + Operators cannot be included directly in the function parameters so the filters + must be provided as a filters dictionary. The trailing ``=`` on the operator may be excluded. Examples: .. code-block:: python # Genre is horror AND thriller - library.search(**{"genre&": ["horror", "thriller"]}) + library.search(filters={"genre&": ["horror", "thriller"]}) # Director is not Steven Spielberg - library.search(**{"director!": "Steven Spielberg"}) + library.search(filters={"director!": "Steven Spielberg"}) # Title starts with Marvel and added before 2021-01-01 - library.search(**{"title<": "Marvel", "addedAt<<": "2021-01-01"}) + library.search(filters={"title<": "Marvel", "addedAt<<": "2021-01-01"}) # Added in the last 30 days using relative dates - library.search(**{"addedAt>>": "30d"}) + library.search(filters={"addedAt>>": "30d"}) # Collection is James Bond and user rating is greater than 8 - library.search(**{"collection": "James Bond", "userRating>>": 8}) + library.search(filters={"collection": "James Bond", "userRating>>": 8}) + + **Using Advanced Filters** + + Any of the Plex filters described above can be combined into a single ``filters`` dictionary that mimics + the advanced filters used in Plex Web with a tree of ``and``/``or`` branches. Each level of the tree must + start with ``and`` (Match all of the following) or ``or`` (Match any of the following) as the dictionary + key, and a list of dictionaries with the desired filters as the dictionary value. + + The following example matches `this <../_static/images/LibrarySection.search_filters.png>`__ advanced filter + in Plex Web. + + Examples: + + .. code-block:: python + + advancedFilters = { + 'and': [ # Match all of the following in this list + { + 'or': [ # Match any of the following in this list + {'title': 'elephant'}, + {'title': 'bunny'} + ] + }, + {'year>>': 1990}, + {'unwatched': True} + ] + } + library.search(filters=advancedFilters) **Using PlexAPI Operators** @@ -1120,28 +1235,8 @@ class LibrarySection(PlexObject): library.search(genre="holiday", viewCount__gte=3) """ - # cleanup the core arguments - args = {} - filter_args = [] - for field, values in list(kwargs.items()): - if field.split('__')[-1] not in OPERATORS: - filter_args.append(self._validateFilterField(field, values, libtype)) - del kwargs[field] - if title is not None: - if isinstance(title, (list, tuple)): - filter_args.append(self._validateFilterField('title', title, libtype)) - else: - args['title'] = title - if sort is not None: - args['sort'] = self._validateSortField(sort, libtype) - if libtype is not None: - args['type'] = utils.searchType(libtype) - - joined_args = utils.joinArgs(args).lstrip('?') - joined_filter_args = '&'.join(filter_args) if filter_args else '' - params = '&'.join([joined_args, joined_filter_args]).strip('&') - key = '/library/sections/%s/all?%s' % (self.key, params) - + key, kwargs = self._buildSearchKey( + title=title, sort=sort, libtype=libtype, limit=limit, filters=filters, returnKwargs=True, **kwargs) return self._search(key, maxresults, container_start, container_size, **kwargs) def _search(self, key, maxresults, container_start, container_size, **kwargs): @@ -1158,7 +1253,7 @@ class LibrarySection(PlexObject): container_size=container_size, **kwargs) if not len(subresults): if offset > self._totalViewSize: - log.info("container_start is higher then the number of items in the library") + log.info("container_start is higher than the number of items in the library") results.extend(subresults) @@ -1239,15 +1334,6 @@ class LibrarySection(PlexObject): if not self.allowSync: raise BadRequest('The requested library is not allowed to sync') - args = {} - filter_args = [] - for field, values in kwargs.items(): - filter_args.append(self._validateFilterField(field, values, libtype)) - if sort is not None: - args['sort'] = self._validateSortField(sort, libtype) - if libtype is not None: - args['type'] = utils.searchType(libtype) - myplex = self._server.myPlexAccount() sync_item = SyncItem(self._server, None) sync_item.title = title if title else self.title @@ -1256,10 +1342,7 @@ class LibrarySection(PlexObject): sync_item.metadataType = self.METADATA_TYPE sync_item.machineIdentifier = self._server.machineIdentifier - joined_args = utils.joinArgs(args).lstrip('?') - joined_filter_args = '&'.join(filter_args) if filter_args else '' - params = '&'.join([joined_args, joined_filter_args]).strip('&') - key = '/library/sections/%s/all?%s' % (self.key, params) + key = self._buildSearchKey(title=title, sort=sort, libtype=libtype, **kwargs) sync_item.location = 'library://%s/directory/%s' % (self.uuid, quote_plus(key)) sync_item.policy = policy @@ -1275,9 +1358,24 @@ 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 createCollection(self, title, items=None, smart=False, limit=None, + libtype=None, sort=None, filters=None, **kwargs): + """ Alias for :func:`~plexapi.server.PlexServer.createCollection` using this + :class:`~plexapi.library.LibrarySection`. + """ + return self._server.createCollection( + title, section=self, items=items, smart=smart, limit=limit, + libtype=libtype, sort=sort, filters=filters, **kwargs) + + def collection(self, title): + """ Returns the collection with the specified title. + + Parameters: + title (str): Title of the item to return. + """ + results = self.collections(title__iexact=title) + if results: + return results[0] def collections(self, **kwargs): """ Returns a list of collections from this library section. @@ -1285,6 +1383,25 @@ class LibrarySection(PlexObject): """ return self.search(libtype='collection', **kwargs) + def createPlaylist(self, title, items=None, smart=False, limit=None, + sort=None, filters=None, **kwargs): + """ Alias for :func:`~plexapi.server.PlexServer.createPlaylist` using this + :class:`~plexapi.library.LibrarySection`. + """ + return self._server.createPlaylist( + title, section=self, items=items, smart=smart, limit=limit, + sort=sort, filters=filters, **kwargs) + + def playlist(self, title): + """ Returns the playlist with the specified title. + + Parameters: + title (str): Title of the item to return. + """ + results = self.playlists(title__iexact=title) + if results: + return results[0] + 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) @@ -1315,6 +1432,14 @@ class MovieSection(LibrarySection): """ Search for a movie. See :func:`~plexapi.library.LibrarySection.search` for usage. """ return self.search(libtype='movie', **kwargs) + def recentlyAddedMovies(self, maxresults=50): + """ Returns a list of recently added movies from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='movie') + 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 @@ -1358,7 +1483,6 @@ class ShowSection(LibrarySection): TAG (str): 'Directory' TYPE (str): 'show' """ - TAG = 'Directory' TYPE = 'show' METADATA_TYPE = 'episode' @@ -1376,13 +1500,29 @@ class ShowSection(LibrarySection): """ Search for an episode. See :func:`~plexapi.library.LibrarySection.search` for usage. """ return self.search(libtype='episode', **kwargs) - def recentlyAdded(self, maxresults=50): + def recentlyAddedShows(self, maxresults=50): + """ Returns a list of recently added shows from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='show') + + def recentlyAddedSeasons(self, maxresults=50): + """ Returns a list of recently added seasons from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='season') + + def recentlyAddedEpisodes(self, maxresults=50): """ Returns a list of recently added episodes from this library section. Parameters: maxresults (int): Max number of items to return (default 50). """ - return self.search(sort='episode.addedAt:desc', maxresults=maxresults) + return self.recentlyAdded(maxresults=maxresults, libtype='episode') def sync(self, videoQuality, limit=None, unwatched=False, **kwargs): """ Add current Show library section as sync item for specified device. @@ -1429,9 +1569,8 @@ class MusicSection(LibrarySection): """ TAG = 'Directory' TYPE = 'artist' - - CONTENT_TYPE = 'audio' METADATA_TYPE = 'track' + CONTENT_TYPE = 'audio' def albums(self): """ Returns a list of :class:`~plexapi.audio.Album` objects in this section. """ @@ -1455,6 +1594,30 @@ class MusicSection(LibrarySection): """ Search for a track. See :func:`~plexapi.library.LibrarySection.search` for usage. """ return self.search(libtype='track', **kwargs) + def recentlyAddedArtists(self, maxresults=50): + """ Returns a list of recently added artists from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='artist') + + def recentlyAddedAlbums(self, maxresults=50): + """ Returns a list of recently added albums from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='album') + + def recentlyAddedTracks(self, maxresults=50): + """ Returns a list of recently added tracks from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + return self.recentlyAdded(maxresults=maxresults, libtype='track') + 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 @@ -1499,8 +1662,8 @@ class PhotoSection(LibrarySection): """ TAG = 'Directory' TYPE = 'photo' - CONTENT_TYPE = 'photo' METADATA_TYPE = 'photo' + CONTENT_TYPE = 'photo' def all(self, libtype=None, **kwargs): """ Returns a list of all items from this library section. @@ -1513,13 +1676,22 @@ class PhotoSection(LibrarySection): 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. """ + """ Search for a photo album. See :func:`~plexapi.library.LibrarySection.search` for usage. """ return self.search(libtype='photoalbum', title=title, **kwargs) def searchPhotos(self, title, **kwargs): """ Search for a photo. See :func:`~plexapi.library.LibrarySection.search` for usage. """ return self.search(libtype='photo', title=title, **kwargs) + def recentlyAddedAlbums(self, maxresults=50): + """ Returns a list of recently added photo albums from this library section. + + Parameters: + maxresults (int): Max number of items to return (default 50). + """ + # Use search() instead of recentlyAdded() because libtype=None + return self.search(sort='addedAt:desc', maxresults=maxresults) + 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 @@ -1699,6 +1871,12 @@ class HubMediaTag(PlexObject): self.tagValue = utils.cast(int, data.attrib.get('tagValue')) self.thumb = data.attrib.get('thumb') + def items(self, *args, **kwargs): + """ Return the list of items within this tag. """ + if not self.key: + raise BadRequest('Key is not defined for this tag: %s' % self.tag) + return self.fetchItems(self.key) + @utils.registerPlexObject class Tag(HubMediaTag): @@ -1822,6 +2000,111 @@ class FilteringType(PlexObject): self.title = data.attrib.get('title') self.type = data.attrib.get('type') + # Add additional manual sorts and fields which are available + # but not exposed on the Plex server + self.sorts += self._manualSorts() + self.fields += self._manualFields() + + def _manualSorts(self): + """ Manually add additional sorts which are available + but not exposed on the Plex server. + """ + # Sorts: key, dir, title + additionalSorts = [ + ('guid', 'asc', 'Guid'), + ('id', 'asc', 'Rating Key'), + ('index', 'asc', '%s Number' % self.type.capitalize()), + ('random', 'asc', 'Random'), + ('summary', 'asc', 'Summary'), + ('tagline', 'asc', 'Tagline'), + ('updatedAt', 'asc', 'Date Updated') + ] + + if self.type == 'season': + additionalSorts.extend([ + ('titleSort', 'asc', 'Title') + ]) + elif self.type == 'track': + # Don't know what this is but it is valid + additionalSorts.extend([ + ('absoluteIndex', 'asc', 'Absolute Index') + ]) + if self.type == 'collection': + additionalSorts.extend([ + ('addedAt', 'asc', 'Date Added') + ]) + + manualSorts = [] + for sortField, sortDir, sortTitle in additionalSorts: + sortXML = ('' + % (sortDir, sortField, sortField, sortTitle)) + manualSorts.append(self._manuallyLoadXML(sortXML, FilteringSort)) + + return manualSorts + + def _manualFields(self): + """ Manually add additional fields which are available + but not exposed on the Plex server. + """ + # Fields: key, type, title + additionalFields = [ + ('guid', 'string', 'Guid'), + ('id', 'integer', 'Rating Key'), + ('index', 'integer', '%s Number' % self.type.capitalize()), + ('lastRatedAt', 'date', '%s Last Rated' % self.type.capitalize()), + ('updatedAt', 'date', 'Date Updated') + ] + + if self.type == 'movie': + additionalFields.extend([ + ('audienceRating', 'integer', 'Audience Rating'), + ('rating', 'integer', 'Critic Rating'), + ('viewOffset', 'integer', 'View Offset') + ]) + elif self.type == 'show': + additionalFields.extend([ + ('audienceRating', 'integer', 'Audience Rating'), + ('originallyAvailableAt', 'date', 'Show Release Date'), + ('rating', 'integer', 'Critic Rating'), + ('unviewedLeafCount', 'integer', 'Episode Unplayed Count') + ]) + elif self.type == 'season': + additionalFields.extend([ + ('addedAt', 'date', 'Date Season Added'), + ('unviewedLeafCount', 'integer', 'Episode Unplayed Count'), + ('year', 'integer', 'Season Year') + ]) + elif self.type == 'episode': + additionalFields.extend([ + ('audienceRating', 'integer', 'Audience Rating'), + ('duration', 'integer', 'Duration'), + ('rating', 'integer', 'Critic Rating'), + ('viewOffset', 'integer', 'View Offset') + ]) + elif self.type == 'artist': + additionalFields.extend([ + ('lastViewedAt', 'date', 'Artist Last Played') + ]) + elif self.type == 'track': + additionalFields.extend([ + ('duration', 'integer', 'Duration'), + ('viewOffset', 'integer', 'View Offset') + ]) + elif self.type == 'collection': + additionalFields.extend([ + ('addedAt', 'date', 'Date Added') + ]) + + prefix = '' if self.type == 'movie' else self.type + '.' + + manualFields = [] + for field, fieldType, fieldTitle in additionalFields: + fieldXML = ('' + % (prefix, field, fieldTitle, fieldType)) + manualFields.append(self._manuallyLoadXML(fieldXML, FilteringField)) + + return manualFields + class FilteringFilter(PlexObject): """ Represents a single Filter object for a :class:`~plexapi.library.FilteringType`. @@ -1850,6 +2133,9 @@ class FilteringSort(PlexObject): Attributes: TAG (str): 'Sort' + active (bool): True if the sort is currently active. + activeDirection (str): The currently active sorting direction. + default (str): The currently active default sorting direction. defaultDirection (str): The default sorting direction. descKey (str): The URL key for sorting with desc. firstCharacterKey (str): API URL path for first character endpoint. @@ -1861,6 +2147,9 @@ class FilteringSort(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data + self.active = utils.cast(bool, data.attrib.get('active', '0')) + self.activeDirection = data.attrib.get('activeDirection') + self.default = data.attrib.get('default') self.defaultDirection = data.attrib.get('defaultDirection') self.descKey = data.attrib.get('descKey') self.firstCharacterKey = data.attrib.get('firstCharacterKey') diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 735bbe1b..3ca69978 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -6,7 +6,6 @@ from urllib.parse import quote_plus from plexapi import log, settings, utils from plexapi.base import PlexObject from plexapi.exceptions import BadRequest -from plexapi.utils import cast @utils.registerPlexObject @@ -51,31 +50,31 @@ class Media(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data - self.aspectRatio = cast(float, data.attrib.get('aspectRatio')) - self.audioChannels = cast(int, data.attrib.get('audioChannels')) + self.aspectRatio = utils.cast(float, data.attrib.get('aspectRatio')) + self.audioChannels = utils.cast(int, data.attrib.get('audioChannels')) self.audioCodec = data.attrib.get('audioCodec') self.audioProfile = data.attrib.get('audioProfile') - self.bitrate = cast(int, data.attrib.get('bitrate')) + self.bitrate = utils.cast(int, data.attrib.get('bitrate')) self.container = data.attrib.get('container') - self.duration = cast(int, data.attrib.get('duration')) - self.height = cast(int, data.attrib.get('height')) - self.id = cast(int, data.attrib.get('id')) - self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets')) - self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming')) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.height = utils.cast(int, data.attrib.get('height')) + self.id = utils.cast(int, data.attrib.get('id')) + self.has64bitOffsets = utils.cast(bool, data.attrib.get('has64bitOffsets')) + self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming')) self.parts = self.findItems(data, MediaPart) - self.proxyType = cast(int, data.attrib.get('proxyType')) + self.proxyType = utils.cast(int, data.attrib.get('proxyType')) self.target = data.attrib.get('target') self.title = data.attrib.get('title') self.videoCodec = data.attrib.get('videoCodec') self.videoFrameRate = data.attrib.get('videoFrameRate') self.videoProfile = data.attrib.get('videoProfile') self.videoResolution = data.attrib.get('videoResolution') - self.width = cast(int, data.attrib.get('width')) + self.width = utils.cast(int, data.attrib.get('width')) 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.iso = utils.cast(int, data.attrib.get('iso')) self.lens = data.attrib.get('lens') self.make = data.attrib.get('make') self.model = data.attrib.get('model') @@ -112,7 +111,7 @@ class MediaPart(PlexObject): 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. + indexes (str, None): sd if the file has generated preview (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. @@ -128,25 +127,25 @@ class MediaPart(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data - self.accessible = cast(bool, data.attrib.get('accessible')) + self.accessible = utils.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.deepAnalysisVersion = utils.cast(int, data.attrib.get('deepAnalysisVersion')) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.exists = utils.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.has64bitOffsets = utils.cast(bool, data.attrib.get('has64bitOffsets')) + self.hasThumbnail = utils.cast(bool, data.attrib.get('hasThumbnail')) + self.id = utils.cast(int, data.attrib.get('id')) self.indexes = data.attrib.get('indexes') self.key = data.attrib.get('key') - self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming')) - self.packetLength = cast(int, data.attrib.get('packetLength')) + self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming')) + self.packetLength = utils.cast(int, data.attrib.get('packetLength')) self.requiredBandwidths = data.attrib.get('requiredBandwidths') - self.size = cast(int, data.attrib.get('size')) + self.size = utils.cast(int, data.attrib.get('size')) self.streams = self._buildStreams(data) - self.syncItemId = cast(int, data.attrib.get('syncItemId')) + self.syncItemId = utils.cast(int, data.attrib.get('syncItemId')) self.syncState = data.attrib.get('syncState') self.videoProfile = data.attrib.get('videoProfile') @@ -157,6 +156,11 @@ class MediaPart(PlexObject): streams.extend(items) return streams + @property + def hasPreviewThumbnails(self): + """ Returns True if the media part has generated preview (BIF) thumbnails. """ + return self.indexes == 'sd' + def videoStreams(self): """ Returns a list of :class:`~plexapi.media.VideoStream` objects in this MediaPart. """ return [stream for stream in self.streams if isinstance(stream, VideoStream)] @@ -228,21 +232,21 @@ class MediaPartStream(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data - self.bitrate = cast(int, data.attrib.get('bitrate')) + self.bitrate = utils.cast(int, data.attrib.get('bitrate')) self.codec = data.attrib.get('codec') - self.default = cast(bool, data.attrib.get('default')) + self.default = utils.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.id = utils.cast(int, data.attrib.get('id')) + self.index = utils.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.selected = utils.cast(bool, data.attrib.get('selected', '0')) + self.streamType = utils.cast(int, data.attrib.get('streamType')) self.title = data.attrib.get('title') - self.type = cast(int, data.attrib.get('streamType')) + self.type = utils.cast(int, data.attrib.get('streamType')) @utils.registerPlexObject @@ -293,38 +297,38 @@ class VideoStream(MediaPartStream): """ Load attribute values from Plex XML response. """ super(VideoStream, self)._loadData(data) self.anamorphic = data.attrib.get('anamorphic') - self.bitDepth = cast(int, data.attrib.get('bitDepth')) - self.cabac = cast(int, data.attrib.get('cabac')) + self.bitDepth = utils.cast(int, data.attrib.get('bitDepth')) + self.cabac = utils.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 = cast(int, data.attrib.get('codedHeight')) - self.codedWidth = cast(int, data.attrib.get('codedWidth')) + self.codedHeight = utils.cast(int, data.attrib.get('codedHeight')) + self.codedWidth = utils.cast(int, data.attrib.get('codedWidth')) self.colorPrimaries = data.attrib.get('colorPrimaries') self.colorRange = data.attrib.get('colorRange') self.colorSpace = data.attrib.get('colorSpace') self.colorTrc = data.attrib.get('colorTrc') - self.DOVIBLCompatID = cast(int, data.attrib.get('DOVIBLCompatID')) - self.DOVIBLPresent = cast(bool, data.attrib.get('DOVIBLPresent')) - self.DOVIELPresent = cast(bool, data.attrib.get('DOVIELPresent')) - self.DOVILevel = cast(int, data.attrib.get('DOVILevel')) - self.DOVIPresent = cast(bool, data.attrib.get('DOVIPresent')) - self.DOVIProfile = cast(int, data.attrib.get('DOVIProfile')) - self.DOVIRPUPresent = cast(bool, data.attrib.get('DOVIRPUPresent')) - self.DOVIVersion = cast(float, data.attrib.get('DOVIVersion')) - self.duration = cast(int, data.attrib.get('duration')) - self.frameRate = cast(float, data.attrib.get('frameRate')) + self.DOVIBLCompatID = utils.cast(int, data.attrib.get('DOVIBLCompatID')) + self.DOVIBLPresent = utils.cast(bool, data.attrib.get('DOVIBLPresent')) + self.DOVIELPresent = utils.cast(bool, data.attrib.get('DOVIELPresent')) + self.DOVILevel = utils.cast(int, data.attrib.get('DOVILevel')) + self.DOVIPresent = utils.cast(bool, data.attrib.get('DOVIPresent')) + self.DOVIProfile = utils.cast(int, data.attrib.get('DOVIProfile')) + self.DOVIRPUPresent = utils.cast(bool, data.attrib.get('DOVIRPUPresent')) + self.DOVIVersion = utils.cast(float, data.attrib.get('DOVIVersion')) + self.duration = utils.cast(int, data.attrib.get('duration')) + self.frameRate = utils.cast(float, data.attrib.get('frameRate')) self.frameRateMode = data.attrib.get('frameRateMode') - self.hasScallingMatrix = cast(bool, data.attrib.get('hasScallingMatrix')) - self.height = cast(int, data.attrib.get('height')) - self.level = cast(int, data.attrib.get('level')) + self.hasScallingMatrix = utils.cast(bool, data.attrib.get('hasScallingMatrix')) + self.height = utils.cast(int, data.attrib.get('height')) + self.level = utils.cast(int, data.attrib.get('level')) self.profile = data.attrib.get('profile') self.pixelAspectRatio = data.attrib.get('pixelAspectRatio') self.pixelFormat = data.attrib.get('pixelFormat') - self.refFrames = cast(int, data.attrib.get('refFrames')) + self.refFrames = utils.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')) + self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier')) + self.width = utils.cast(int, data.attrib.get('width')) @utils.registerPlexObject @@ -362,23 +366,23 @@ class AudioStream(MediaPartStream): """ Load attribute values from Plex XML response. """ super(AudioStream, self)._loadData(data) self.audioChannelLayout = data.attrib.get('audioChannelLayout') - self.bitDepth = cast(int, data.attrib.get('bitDepth')) + self.bitDepth = utils.cast(int, data.attrib.get('bitDepth')) self.bitrateMode = data.attrib.get('bitrateMode') - self.channels = cast(int, data.attrib.get('channels')) - self.duration = cast(int, data.attrib.get('duration')) + self.channels = utils.cast(int, data.attrib.get('channels')) + self.duration = utils.cast(int, data.attrib.get('duration')) self.profile = data.attrib.get('profile') - self.samplingRate = cast(int, data.attrib.get('samplingRate')) - self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier')) + self.samplingRate = utils.cast(int, data.attrib.get('samplingRate')) + self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier')) 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.albumGain = utils.cast(float, data.attrib.get('albumGain')) + self.albumPeak = utils.cast(float, data.attrib.get('albumPeak')) + self.albumRange = utils.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.gain = utils.cast(float, data.attrib.get('gain')) + self.loudness = utils.cast(float, data.attrib.get('loudness')) + self.lra = utils.cast(float, data.attrib.get('lra')) + self.peak = utils.cast(float, data.attrib.get('peak')) self.startRamp = data.attrib.get('startRamp') @@ -402,7 +406,7 @@ class SubtitleStream(MediaPartStream): """ Load attribute values from Plex XML response. """ super(SubtitleStream, self)._loadData(data) self.container = data.attrib.get('container') - self.forced = cast(bool, data.attrib.get('forced', '0')) + self.forced = utils.cast(bool, data.attrib.get('forced', '0')) self.format = data.attrib.get('format') self.headerCompression = data.attrib.get('headerCompression') self.transient = data.attrib.get('transient') @@ -426,9 +430,9 @@ class LyricStream(MediaPartStream): """ Load attribute values from Plex XML response. """ super(LyricStream, self)._loadData(data) self.format = data.attrib.get('format') - self.minLines = cast(int, data.attrib.get('minLines')) + self.minLines = utils.cast(int, data.attrib.get('minLines')) self.provider = data.attrib.get('provider') - self.timed = cast(bool, data.attrib.get('timed', '0')) + self.timed = utils.cast(bool, data.attrib.get('timed', '0')) @utils.registerPlexObject @@ -491,36 +495,36 @@ class TranscodeSession(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data - self.audioChannels = cast(int, data.attrib.get('audioChannels')) + self.audioChannels = utils.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.complete = utils.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.duration = utils.cast(int, data.attrib.get('duration')) + self.height = utils.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.maxOffsetAvailable = utils.cast(float, data.attrib.get('maxOffsetAvailable')) + self.minOffsetAvailable = utils.cast(float, data.attrib.get('minOffsetAvailable')) + self.progress = utils.cast(float, data.attrib.get('progress')) self.protocol = data.attrib.get('protocol') - self.remaining = cast(int, data.attrib.get('remaining')) - self.size = cast(int, data.attrib.get('size')) + self.remaining = utils.cast(int, data.attrib.get('remaining')) + self.size = utils.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.speed = utils.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.throttled = utils.cast(bool, data.attrib.get('throttled', '0')) + self.timestamp = utils.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.transcodeHwFullPipeline = utils.cast(bool, data.attrib.get('transcodeHwFullPipeline', '0')) + self.transcodeHwRequested = utils.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')) + self.width = utils.cast(int, data.attrib.get('width')) @utils.registerPlexObject @@ -558,6 +562,13 @@ class Optimized(PlexObject): self.target = data.attrib.get('target') self.targetTagID = data.attrib.get('targetTagID') + def items(self): + """ Returns a list of all :class:`~plexapi.media.Video` objects + in this optimized item. + """ + key = '%s/%s/items' % (self._initpath, self.id) + return self.fetchItems(key) + def remove(self): """ Remove an Optimized item""" key = '%s/%s' % (self._initpath, self.id) @@ -641,59 +652,43 @@ class MediaTag(PlexObject): the construct used for things such as Country, Director, Genre, etc. Attributes: - server (:class:`~plexapi.server.PlexServer`): Server this client is connected to. + filter (str): The library filter for the tag. id (id): Tag ID (This seems meaningless except to use it as a unique id). - role (str): Unknown + key (str): API URL (/library/section//all?). + role (str): The name of the character role for :class:`~plexapi.media.Role` only. tag (str): Name of the tag. This will be Animation, SciFi etc for Genres. The name of person for Directors and Roles (ex: Animation, Stephen Graham, etc). - : Attributes only applicable in search results from - PlexServer :func:`~plexapi.server.PlexServer.search`. They provide details of which - library section the tag was found as well as the url to dig deeper into the results. - - * key (str): API URL to dig deeper into this tag (ex: /library/sections/1/all?actor=9081). - * librarySectionID (int): Section ID this tag was generated from. - * librarySectionTitle (str): Library section title this tag was found. - * librarySectionType (str): Media type of the library section this tag was found. - * tagType (int): Tag type ID. - * thumb (str): URL to thumbnail image. + thumb (str): URL to thumbnail image for :class:`~plexapi.media.Role` only. """ def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data - self.id = cast(int, data.attrib.get('id')) + self.filter = data.attrib.get('filter') + self.id = utils.cast(int, data.attrib.get('id')) + self.key = data.attrib.get('key') self.role = data.attrib.get('role') self.tag = data.attrib.get('tag') - # additional attributes only from hub search - self.key = data.attrib.get('key') - self.librarySectionID = cast(int, data.attrib.get('librarySectionID')) - self.librarySectionTitle = data.attrib.get('librarySectionTitle') - self.librarySectionType = data.attrib.get('librarySectionType') - self.tagType = cast(int, data.attrib.get('tagType')) self.thumb = data.attrib.get('thumb') - def items(self, *args, **kwargs): - """ Return the list of items within this tag. This function is only applicable - in search results from PlexServer :func:`~plexapi.server.PlexServer.search`. - """ + parent = self._parent() + self._librarySectionID = utils.cast(int, parent._data.attrib.get('librarySectionID')) + self._librarySectionKey = parent._data.attrib.get('librarySectionKey') + self._librarySectionTitle = parent._data.attrib.get('librarySectionTitle') + self._parentType = parent.TYPE + + if self._librarySectionKey and self.filter: + self.key = '%s/all?%s&type=%s' % ( + self._librarySectionKey, self.filter, utils.searchType(self._parentType)) + + def items(self): + """ Return the list of items within this tag. """ if not self.key: - raise BadRequest('Key is not defined for this tag: %s' % self.tag) + raise BadRequest('Key is not defined for this tag: %s. ' + 'Reload the parent object.' % self.tag) return self.fetchItems(self.key) -class GuidTag(PlexObject): - """ Base class for guid tags used only for Guids, as they contain only a string identifier - - Attributes: - 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') - - @utils.registerPlexObject class Collection(MediaTag): """ Represents a single Collection media tag. @@ -705,36 +700,11 @@ class Collection(MediaTag): TAG = 'Collection' FILTER = 'collection' - -@utils.registerPlexObject -class Label(MediaTag): - """ Represents a single Label media tag. - - Attributes: - TAG (str): 'Label' - FILTER (str): 'label' - """ - TAG = 'Label' - FILTER = 'label' - - -@utils.registerPlexObject -class Tag(MediaTag): - """ Represents a single Tag media tag. - - Attributes: - TAG (str): 'Tag' - FILTER (str): 'tag' - """ - TAG = 'Tag' - FILTER = 'tag' - - def _loadData(self, data): - self._data = data - self.id = cast(int, data.attrib.get('id', 0)) - self.filter = data.attrib.get('filter') - self.tag = data.attrib.get('tag') - self.title = self.tag + def collection(self): + """ Return the :class:`~plexapi.collection.Collection` object for this collection tag. + """ + key = '%s/collections' % self._librarySectionKey + return self.fetchItem(key, etag='Directory', index=self.id) @utils.registerPlexObject @@ -774,13 +744,15 @@ class Genre(MediaTag): @utils.registerPlexObject -class Guid(GuidTag): - """ Represents a single Guid media tag. +class Label(MediaTag): + """ Represents a single Label media tag. Attributes: - TAG (str): 'Guid' + TAG (str): 'Label' + FILTER (str): 'label' """ - TAG = "Guid" + TAG = 'Label' + FILTER = 'label' @utils.registerPlexObject @@ -795,60 +767,6 @@ class Mood(MediaTag): FILTER = 'mood' -@utils.registerPlexObject -class Style(MediaTag): - """ Represents a single Style media tag. - - Attributes: - TAG (str): 'Style' - FILTER (str): 'style' - """ - TAG = 'Style' - FILTER = 'style' - - -class BaseImage(PlexObject): - """ Base class for all Art, Banner, and Poster objects. - - Attributes: - TAG (str): 'Photo' - key (str): API URL (/library/metadata/). - provider (str): The source of the poster or art. - ratingKey (str): Unique key identifying the poster or art. - selected (bool): True if the poster or art is currently selected. - thumb (str): The URL to retrieve the poster or art thumbnail. - """ - TAG = 'Photo' - - def _loadData(self, data): - self._data = data - self.key = data.attrib.get('key') - self.provider = data.attrib.get('provider') - self.ratingKey = data.attrib.get('ratingKey') - self.selected = cast(bool, data.attrib.get('selected')) - self.thumb = data.attrib.get('thumb') - - def select(self): - key = self._initpath[:-1] - data = '%s?url=%s' % (key, quote_plus(self.ratingKey)) - try: - self._server.query(data, method=self._server._session.put) - except xml.etree.ElementTree.ParseError: - pass - - -class Art(BaseImage): - """ Represents a single Art object. """ - - -class Banner(BaseImage): - """ Represents a single Banner object. """ - - -class Poster(BaseImage): - """ Represents a single Poster object. """ - - @utils.registerPlexObject class Producer(MediaTag): """ Represents a single Producer media tag. @@ -885,6 +803,30 @@ class Similar(MediaTag): FILTER = 'similar' +@utils.registerPlexObject +class Style(MediaTag): + """ Represents a single Style media tag. + + Attributes: + TAG (str): 'Style' + FILTER (str): 'style' + """ + TAG = 'Style' + FILTER = 'style' + + +@utils.registerPlexObject +class Tag(MediaTag): + """ Represents a single Tag media tag. + + Attributes: + TAG (str): 'Tag' + FILTER (str): 'tag' + """ + TAG = 'Tag' + FILTER = 'tag' + + @utils.registerPlexObject class Writer(MediaTag): """ Represents a single Writer media tag. @@ -897,6 +839,98 @@ class Writer(MediaTag): FILTER = 'writer' +class GuidTag(PlexObject): + """ Base class for guid tags used only for Guids, as they contain only a string identifier + + Attributes: + 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') + + +@utils.registerPlexObject +class Guid(GuidTag): + """ Represents a single Guid media tag. + + Attributes: + TAG (str): 'Guid' + """ + TAG = 'Guid' + + +@utils.registerPlexObject +class Review(PlexObject): + """ Represents a single Review for a Movie. + + Attributes: + TAG (str): 'Review' + filter (str): filter for reviews? + id (int): The ID of the review. + image (str): The image uri for the review. + link (str): The url to the online review. + source (str): The source of the review. + tag (str): The name of the reviewer. + text (str): The text of the review. + """ + TAG = 'Review' + + def _loadData(self, data): + self._data = data + self.filter = data.attrib.get('filter') + self.id = utils.cast(int, data.attrib.get('id', 0)) + self.image = data.attrib.get('image') + self.link = data.attrib.get('link') + self.source = data.attrib.get('source') + self.tag = data.attrib.get('tag') + self.text = data.attrib.get('text') + + +class BaseImage(PlexObject): + """ Base class for all Art, Banner, and Poster objects. + + Attributes: + TAG (str): 'Photo' + key (str): API URL (/library/metadata/). + provider (str): The source of the poster or art. + ratingKey (str): Unique key identifying the poster or art. + selected (bool): True if the poster or art is currently selected. + thumb (str): The URL to retrieve the poster or art thumbnail. + """ + TAG = 'Photo' + + def _loadData(self, data): + self._data = data + self.key = data.attrib.get('key') + self.provider = data.attrib.get('provider') + self.ratingKey = data.attrib.get('ratingKey') + self.selected = utils.cast(bool, data.attrib.get('selected')) + self.thumb = data.attrib.get('thumb') + + def select(self): + key = self._initpath[:-1] + data = '%s?url=%s' % (key, quote_plus(self.ratingKey)) + try: + self._server.query(data, method=self._server._session.put) + except xml.etree.ElementTree.ParseError: + pass + + +class Art(BaseImage): + """ Represents a single Art object. """ + + +class Banner(BaseImage): + """ Represents a single Banner object. """ + + +class Poster(BaseImage): + """ Represents a single Poster object. """ + + @utils.registerPlexObject class Chapter(PlexObject): """ Represents a single Writer media tag. @@ -908,13 +942,13 @@ class Chapter(PlexObject): def _loadData(self, data): self._data = data - self.id = cast(int, data.attrib.get('id', 0)) + self.id = utils.cast(int, data.attrib.get('id', 0)) self.filter = data.attrib.get('filter') # I couldn't filter on it anyways self.tag = data.attrib.get('tag') self.title = self.tag - self.index = cast(int, data.attrib.get('index')) - self.start = cast(int, data.attrib.get('startTimeOffset')) - self.end = cast(int, data.attrib.get('endTimeOffset')) + self.index = utils.cast(int, data.attrib.get('index')) + self.start = utils.cast(int, data.attrib.get('startTimeOffset')) + self.end = utils.cast(int, data.attrib.get('endTimeOffset')) @utils.registerPlexObject @@ -935,8 +969,8 @@ class Marker(PlexObject): def _loadData(self, data): self._data = data self.type = data.attrib.get('type') - self.start = cast(int, data.attrib.get('startTimeOffset')) - self.end = cast(int, data.attrib.get('endTimeOffset')) + self.start = utils.cast(int, data.attrib.get('startTimeOffset')) + self.end = utils.cast(int, data.attrib.get('endTimeOffset')) @utils.registerPlexObject @@ -951,7 +985,7 @@ class Field(PlexObject): def _loadData(self, data): self._data = data self.name = data.attrib.get('name') - self.locked = cast(bool, data.attrib.get('locked')) + self.locked = utils.cast(bool, data.attrib.get('locked')) @utils.registerPlexObject @@ -973,7 +1007,7 @@ class SearchResult(PlexObject): self.guid = data.attrib.get('guid') self.lifespanEnded = data.attrib.get('lifespanEnded') self.name = data.attrib.get('name') - self.score = cast(int, data.attrib.get('score')) + self.score = utils.cast(int, data.attrib.get('score')) self.year = data.attrib.get('year') @@ -1018,7 +1052,7 @@ class AgentMediaType(Agent): return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p]) def _loadData(self, data): - self.mediaType = cast(int, data.attrib.get('mediaType')) + self.mediaType = utils.cast(int, data.attrib.get('mediaType')) self.name = data.attrib.get('name') self.languageCode = [] for code in data: diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index 25795c4a..2ff12a20 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -2,7 +2,7 @@ from urllib.parse import quote_plus, urlencode from plexapi import media, settings, utils -from plexapi.exceptions import NotFound +from plexapi.exceptions import BadRequest, NotFound class AdvancedSettingsMixin(object): @@ -10,15 +10,8 @@ class AdvancedSettingsMixin(object): 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) - - return items + return self.findItems(data, settings.Preferences, rtag='Preferences') def preference(self, pref): """ Returns a :class:`~plexapi.settings.Preferences` object for the specified pref. @@ -39,13 +32,18 @@ class AdvancedSettingsMixin(object): """ Edit a Plex object's advanced settings. """ data = {} key = '%s/prefs?' % self.key - preferences = {pref.id: list(pref.enumValues.keys()) for pref in self.preferences()} + preferences = {pref.id: pref for pref in self.preferences() if pref.enumValues} for settingID, value in kwargs.items(): - enumValues = preferences.get(settingID) - if value in enumValues: + try: + pref = preferences[settingID] + except KeyError: + raise NotFound('%s not found in %s' % (value, list(preferences.keys()))) + + enumValues = pref.enumValues + if enumValues.get(value, enumValues.get(str(value))): data[settingID] = value else: - raise NotFound('%s not found in %s' % (value, enumValues)) + raise NotFound('%s not found in %s' % (value, list(enumValues))) url = key + urlencode(data) self._server.query(url, method=self._server._session.put) @@ -187,6 +185,26 @@ class PosterMixin(PosterUrlMixin): poster.select() +class RatingMixin(object): + """ Mixin for Plex objects that can have user star ratings. """ + + def rate(self, rating=None): + """ Rate the Plex object. Note: Plex ratings are displayed out of 5 stars (e.g. rating 7.0 = 3.5 stars). + + Parameters: + rating (float, optional): Rating from 0 to 10. Exclude to reset the rating. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If the rating is invalid. + """ + if rating is None: + rating = -1 + elif not isinstance(rating, (int, float)) or rating < 0 or rating > 10: + raise BadRequest('Rating must be between 0 to 10.') + key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rating) + self._server.query(key, method=self._server._session.put) + + class SplitMergeMixin(object): """ Mixin for Plex objects that can be split and merged. """ diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index 5e320e14..fe856f34 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -14,7 +14,6 @@ from plexapi.library import LibrarySection from plexapi.server import PlexServer from plexapi.sonos import PlexSonosClient from plexapi.sync import SyncItem, SyncList -from plexapi.utils import joinArgs from requests.status_codes import _codes as codes @@ -76,6 +75,7 @@ class MyPlexAccount(PlexObject): 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 + OPTOUTS = 'https://plex.tv/api/v2/user/%(userUUID)s/settings/opt_outs' # get LINK = 'https://plex.tv/api/v2/pins/link' # put # Hub sections VOD = 'https://vod.provider.plex.tv/' # get @@ -128,26 +128,16 @@ class MyPlexAccount(PlexObject): self.title = data.attrib.get('title') self.username = data.attrib.get('username') self.uuid = data.attrib.get('uuid') - subscription = data.find('subscription') + subscription = data.find('subscription') self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active')) self.subscriptionStatus = subscription.attrib.get('status') self.subscriptionPlan = subscription.attrib.get('plan') + self.subscriptionFeatures = self.listAttrs(subscription, 'id', etag='feature') - self.subscriptionFeatures = [] - for feature in subscription.iter('feature'): - self.subscriptionFeatures.append(feature.attrib.get('id')) + self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role') - roles = data.find('roles') - self.roles = [] - if roles is not None: - for role in roles.iter('role'): - self.roles.append(role.attrib.get('id')) - - entitlements = data.find('entitlements') - self.entitlements = [] - for entitlement in entitlements.iter('entitlement'): - self.entitlements.append(entitlement.attrib.get('id')) + self.entitlements = self.listAttrs(data, 'id', rtag='entitlements', etag='entitlement') # TODO: Fetch missing MyPlexAccount attributes self.profile_settings = None @@ -460,7 +450,7 @@ class MyPlexAccount(PlexObject): if isinstance(allowChannels, dict): params['filterMusic'] = self._filterDictToStr(filterMusic or {}) if params: - url += joinArgs(params) + url += utils.joinArgs(params) response_filters = self.query(url, self._session.put) return response_servers, response_filters @@ -470,6 +460,7 @@ class MyPlexAccount(PlexObject): Parameters: username (str): Username, email or id of the user to return. """ + username = str(username) for user in self.users(): # Home users don't have email, username etc. if username.lower() == user.title.lower(): @@ -698,6 +689,13 @@ class MyPlexAccount(PlexObject): elem = ElementTree.fromstring(req.text) return self.findItems(elem) + def onlineMediaSources(self): + """ Returns a list of user account Online Media Sources settings :class:`~plexapi.myplex.AccountOptOut` + """ + url = self.OPTOUTS % {'userUUID': self.uuid} + elem = self.query(url) + return self.findItems(elem, cls=AccountOptOut, etag='optOut') + def link(self, pin): """ Link a device to the account using a pin code. @@ -884,13 +882,7 @@ class MyPlexServerShare(PlexObject): """ url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id) data = self._server.query(url) - sections = [] - - for section in data.iter('Section'): - if ElementTree.iselement(section): - sections.append(Section(self, section, url)) - - return sections + return self.findItems(data, Section, rtag='SharedServer') def history(self, maxresults=9999999, mindate=None): """ Get all Play History for a user in this shared server. @@ -1075,7 +1067,7 @@ class MyPlexDevice(PlexObject): self.screenDensity = data.attrib.get('screenDensity') self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) - self.connections = [connection.attrib.get('uri') for connection in data.iter('Connection')] + self.connections = self.listAttrs(data, 'uri', etag='Connection') def connect(self, timeout=None): """ Returns a new :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer` @@ -1341,3 +1333,54 @@ def _chooseConnection(ctype, name, results): 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)) + + +class AccountOptOut(PlexObject): + """ Represents a single AccountOptOut + 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' + + Attributes: + TAG (str): optOut + key (str): Online Media Source key + value (str): Online Media Source opt_in, opt_out, or opt_out_managed + """ + TAG = 'optOut' + CHOICES = {'opt_in', 'opt_out', 'opt_out_managed'} + + def _loadData(self, data): + self.key = data.attrib.get('key') + self.value = data.attrib.get('value') + + def _updateOptOut(self, option): + """ Sets the Online Media Sources option. + + Parameters: + option (str): see CHOICES + + Raises: + :exc:`~plexapi.exceptions.NotFound`: ``option`` str not found in CHOICES. + """ + if option not in self.CHOICES: + raise NotFound('%s not found in available choices: %s' % (option, self.CHOICES)) + url = self._server.OPTOUTS % {'userUUID': self._server.uuid} + params = {'key': self.key, 'value': option} + self._server.query(url, method=self._server._session.post, params=params) + self.value = option # assume query successful and set the value to option + + def optIn(self): + """ Sets the Online Media Source to "Enabled". """ + self._updateOptOut('opt_in') + + def optOut(self): + """ Sets the Online Media Source to "Disabled". """ + self._updateOptOut('opt_out') + + def optOutManaged(self): + """ Sets the Online Media Source to "Disabled for Managed Users". + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When trying to opt out music. + """ + if self.key == 'tv.plex.provider.music': + raise BadRequest('%s does not have the option to opt out managed users.' % self.key) + self._updateOptOut('opt_out_managed') diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index a8307333..f3196663 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -4,11 +4,11 @@ from urllib.parse import quote_plus from plexapi import media, utils, video from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest -from plexapi.mixins import ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, TagMixin +from plexapi.mixins import ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, RatingMixin, TagMixin @utils.registerPlexObject -class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin): +class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin): """ Represents a single Photoalbum (collection of photos). Attributes: @@ -21,6 +21,7 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin): 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/). + lastRatedAt (datetime): Datetime the photo album was last rated. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. @@ -32,7 +33,7 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin): 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). + userRating (float): Rating of the photo album (0.0 - 10.0) equaling (0 stars - 5 stars). """ TAG = 'Directory' TYPE = 'photo' @@ -46,6 +47,7 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin): 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.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') @@ -57,7 +59,7 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin): 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.userRating = utils.cast(float, data.attrib.get('userRating')) def album(self, title): """ Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title. @@ -137,7 +139,7 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin): @utils.registerPlexObject -class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, TagMixin): +class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin, TagMixin): """ Represents a single Photo. Attributes: @@ -150,6 +152,7 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, TagMixin): 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/). + lastRatedAt (datetime): Datetime the photo was last rated. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. @@ -170,6 +173,7 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, TagMixin): titleSort (str): Title to use when sorting (defaults to title). type (str): 'photo' updatedAt (datatime): Datetime the photo was updated. + userRating (float): Rating of the photo (0.0 - 10.0) equaling (0 stars - 5 stars). year (int): Year the photo was taken. """ TAG = 'Photo' @@ -186,6 +190,7 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, TagMixin): self.guid = data.attrib.get('guid') self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key', '') + self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') @@ -206,6 +211,7 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, TagMixin): 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')) self.year = utils.cast(int, data.attrib.get('year')) def photoalbum(self): @@ -226,7 +232,7 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, TagMixin): """ This does not exist in plex xml response but is added to have a common interface to get the locations of the photo. - Retruns: + Returns: 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] diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index 531de39c..c99626d0 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- -from urllib.parse import quote_plus +import re +from urllib.parse import quote_plus, unquote from plexapi import utils from plexapi.base import Playable, PlexPartialObject @@ -7,7 +8,7 @@ from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection from plexapi.mixins import ArtMixin, PosterMixin from plexapi.playqueue import PlayQueue -from plexapi.utils import cast, toDatetime +from plexapi.utils import deprecated @utils.registerPlexObject @@ -20,9 +21,11 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): 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/) + content (str): The filter URI string for smart playlists. 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). + icon (str): Icon URI string for smart playlists. key (str): API URL (/playlist/). leafCount (int): Number of items in the playlist view. playlistType (str): 'audio', 'video', or 'photo' @@ -39,22 +42,25 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): def _loadData(self, data): """ 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.addedAt = utils.toDatetime(data.attrib.get('addedAt')) + self.allowSync = utils.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.content = data.attrib.get('content') + self.duration = utils.cast(int, data.attrib.get('duration')) + self.durationInSeconds = utils.cast(int, data.attrib.get('durationInSeconds')) + self.icon = data.attrib.get('icon') self.guid = data.attrib.get('guid') self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50 - self.leafCount = cast(int, data.attrib.get('leafCount')) + self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.playlistType = data.attrib.get('playlistType') - self.ratingKey = cast(int, data.attrib.get('ratingKey')) - self.smart = cast(bool, data.attrib.get('smart')) + self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self.smart = utils.cast(bool, data.attrib.get('smart')) self.summary = data.attrib.get('summary') self.title = data.attrib.get('title') self.type = data.attrib.get('type') - self.updatedAt = toDatetime(data.attrib.get('updatedAt')) + self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self._items = None # cache for self.items + self._section = None # cache for self.section def __len__(self): # pragma: no cover return len(self.items()) @@ -63,6 +69,12 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): for item in self.items(): yield item + def __contains__(self, other): # pragma: no cover + return any(i.key == other.key for i in self.items()) + + def __getitem__(self, key): # pragma: no cover + return self.items()[key] + @property def thumb(self): """ Alias to self.composite. """ @@ -70,6 +82,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): @property def metadataType(self): + """ Returns the type of metadata in the playlist (movie, track, or photo). """ if self.isVideo: return 'movie' elif self.isAudio: @@ -81,27 +94,54 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): @property def isVideo(self): + """ Returns True if this is a video playlist. """ return self.playlistType == 'video' @property def isAudio(self): + """ Returns True if this is an audio playlist. """ return self.playlistType == 'audio' @property def isPhoto(self): + """ Returns True if this is a photo playlist. """ return self.playlistType == 'photo' - def __contains__(self, other): # pragma: no cover - return any(i.key == other.key for i in self.items()) + def section(self): + """ Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to. - def __getitem__(self, key): # pragma: no cover - return self.items()[key] + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to get the section for a regular playlist. + :class:`plexapi.exceptions.Unsupported`: When unable to determine the library section. + """ + if not self.smart: + raise BadRequest('Regular playlists are not associated with a library.') + + if self._section is None: + # Try to parse the library section from the content URI string + match = re.search(r'/library/sections/(\d+)/all', unquote(self.content or '')) + if match: + sectionKey = int(match.group(1)) + self._section = self._server.library.sectionByID(sectionKey) + return self._section + + # Try to get the library section from the first item in the playlist + if self.items(): + self._section = self.items()[0].section() + return self._section + + raise Unsupported('Unable to determine the library section') + + return self._section def item(self, title): """ Returns the item in the playlist that matches the specified title. Parameters: title (str): Title of the item to return. + + Raises: + :class:`plexapi.exceptions.NotFound`: When the item is not found in the playlist. """ for item in self.items(): if item.title.lower() == title.lower(): @@ -111,7 +151,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): def items(self): """ Returns a list of all items in the playlist. """ if self._items is None: - key = '/playlists/%s/items' % self.ratingKey + key = '%s/items' % self.key items = self.fetchItems(key) self._items = items return self._items @@ -120,74 +160,170 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): """ Alias to :func:`~plexapi.playlist.Playlist.item`. """ return self.item(title) + def _getPlaylistItemID(self, item): + """ Match an item to a playlist item and return the item playlistItemID. """ + for _item in self.items(): + if _item.ratingKey == item.ratingKey: + return _item.playlistItemID + raise NotFound('Item with title "%s" not found in the playlist' % item.title) + def addItems(self, items): - """ Add items to a playlist. """ - if not isinstance(items, (list, tuple)): + """ Add items to the playlist. + + Parameters: + items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to be added to the playlist. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to add items to a smart playlist. + """ + if self.smart: + raise BadRequest('Cannot add items to a smart playlist.') + + if items and not isinstance(items, (list, tuple)): items = [items] + ratingKeys = [] for item in items: if item.listType != self.playlistType: # pragma: no cover raise BadRequest('Can not mix media types when building a playlist: %s and %s' % (self.playlistType, item.listType)) ratingKeys.append(str(item.ratingKey)) - uuid = items[0].section().uuid - ratingKeys = ','.join(ratingKeys) - key = '%s/items%s' % (self.key, utils.joinArgs({ - 'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys) - })) - result = self._server.query(key, method=self._server._session.put) - self.reload() - return result + ratingKeys = ','.join(ratingKeys) + uri = '%s/library/metadata/%s' % (self._server._uriRoot(), ratingKeys) + + key = '%s/items%s' % (self.key, utils.joinArgs({ + 'uri': uri + })) + self._server.query(key, method=self._server._session.put) + + @deprecated('use "removeItems" instead', stacklevel=3) def removeItem(self, item): - """ Remove a file from a playlist. """ - key = '%s/items/%s' % (self.key, item.playlistItemID) - result = self._server.query(key, method=self._server._session.delete) - self.reload() - return result + self.removeItems(item) + + def removeItems(self, items): + """ Remove items from the playlist. + + Parameters: + items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to be removed from the playlist. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to remove items from a smart playlist. + :class:`plexapi.exceptions.NotFound`: When the item does not exist in the playlist. + """ + if self.smart: + raise BadRequest('Cannot remove items from a smart playlist.') + + if items and not isinstance(items, (list, tuple)): + items = [items] + + for item in items: + playlistItemID = self._getPlaylistItemID(item) + key = '%s/items/%s' % (self.key, playlistItemID) + self._server.query(key, method=self._server._session.delete) def moveItem(self, item, after=None): - """ Move a to a new position in playlist. """ - key = '%s/items/%s/move' % (self.key, item.playlistItemID) + """ Move an item to a new position in playlist. + + Parameters: + items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to be moved in the playlist. + after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to move the item after in the playlist. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart playlist. + :class:`plexapi.exceptions.NotFound`: When the item or item after does not exist in the playlist. + """ + if self.smart: + raise BadRequest('Cannot move items in a smart playlist.') + + playlistItemID = self._getPlaylistItemID(item) + key = '%s/items/%s/move' % (self.key, playlistItemID) + if after: - key += '?after=%s' % after.playlistItemID - result = self._server.query(key, method=self._server._session.put) - self.reload() - return result + afterPlaylistItemID = self._getPlaylistItemID(after) + key += '?after=%s' % afterPlaylistItemID + + self._server.query(key, method=self._server._session.put) + + def updateFilters(self, limit=None, sort=None, filters=None, **kwargs): + """ Update the filters for a smart playlist. + + Parameters: + limit (int): Limit the number of items in the playlist. + sort (str or list, optional): A string of comma separated sort fields + or a list of sort fields in the format ``column:dir``. + See :func:`~plexapi.library.LibrarySection.search` for more info. + filters (dict): A dictionary of advanced filters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + **kwargs (dict): Additional custom filters to apply to the search results. + See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying update filters for a regular playlist. + """ + if not self.smart: + raise BadRequest('Cannot update filters for a regular playlist.') + + section = self.section() + searchKey = section._buildSearchKey( + sort=sort, libtype=section.METADATA_TYPE, limit=limit, filters=filters, **kwargs) + uri = '%s%s' % (self._server._uriRoot(), searchKey) + + key = '%s/items%s' % (self.key, utils.joinArgs({ + 'uri': uri + })) + self._server.query(key, method=self._server._session.put) def edit(self, title=None, summary=None): - """ Edit playlist. """ - key = '/library/metadata/%s%s' % (self.ratingKey, utils.joinArgs({'title': title, 'summary': summary})) - result = self._server.query(key, method=self._server._session.put) - self.reload() - return result + """ Edit the playlist. + + Parameters: + title (str, optional): The title of the playlist. + summary (str, optional): The summary of the playlist. + """ + args = {} + if title: + args['title'] = title + if summary: + args['summary'] = summary + + key = '%s%s' % (self.key, utils.joinArgs(args)) + self._server.query(key, method=self._server._session.put) def delete(self): - """ Delete playlist. """ - return self._server.query(self.key, method=self._server._session.delete) + """ Delete the playlist. """ + self._server.query(self.key, method=self._server._session.delete) def playQueue(self, *args, **kwargs): - """ Create a playqueue from this playlist. """ + """ Returns a new :class:`~plexapi.playqueue.PlayQueue` from the playlist. """ return PlayQueue.create(self._server, self, *args, **kwargs) @classmethod def _create(cls, server, title, items): - """ Create a playlist. """ + """ Create a regular playlist. """ if not items: - raise BadRequest('Must include items to add when creating new playlist') - + raise BadRequest('Must include items to add when creating new playlist.') + if items and not isinstance(items, (list, tuple)): items = [items] + + listType = items[0].listType ratingKeys = [] for item in items: - if item.listType != items[0].listType: # pragma: no cover - raise BadRequest('Can not mix media types when building a playlist') + if item.listType != listType: # pragma: no cover + raise BadRequest('Can not mix media types when building a playlist.') ratingKeys.append(str(item.ratingKey)) + ratingKeys = ','.join(ratingKeys) - uuid = items[0].section().uuid + uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys) + key = '/playlists%s' % utils.joinArgs({ - 'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys), - 'type': items[0].listType, + 'uri': uri, + 'type': listType, 'title': title, 'smart': 0 }) @@ -195,54 +331,15 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): return cls(server, data, initpath=key) @classmethod - def create(cls, server, title, items=None, section=None, limit=None, smart=False, **kwargs): - """Create a playlist. - - Parameters: - server (:class:`~plexapi.server.PlexServer`): Server your connected to. - title (str): Title of the playlist. - items (Iterable): Iterable of objects that should be in the playlist. - section (:class:`~plexapi.library.LibrarySection`, str): - limit (int): default None. - 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. - """ - if smart: - return cls._createSmart(server, title, section, limit, **kwargs) - - else: - return cls._create(server, title, items) - - @classmethod - def _createSmart(cls, server, title, section, limit=None, **kwargs): - """ Create a Smart playlist. """ - + def _createSmart(cls, server, title, section, limit=None, sort=None, filters=None, **kwargs): + """ Create a smart playlist. """ if not isinstance(section, LibrarySection): section = server.library.section(section) - sectionType = utils.searchType(section.type) - sectionId = section.key - uuid = section.uuid - uri = 'library://%s/directory//library/sections/%s/all?type=%s' % (uuid, - sectionId, - sectionType) - if limit: - uri = uri + '&limit=%s' % str(limit) + searchKey = section._buildSearchKey( + sort=sort, libtype=section.METADATA_TYPE, limit=limit, filters=filters, **kwargs) + uri = '%s%s' % (server._uriRoot(), searchKey) - for category, value in kwargs.items(): - sectionChoices = section.listFilterChoices(category) - for choice in sectionChoices: - if str(choice.title).lower() == str(value).lower(): - uri = uri + '&%s=%s' % (category.lower(), str(choice.key)) - - uri = uri + '&sourceType=%s' % sectionType key = '/playlists%s' % utils.joinArgs({ 'uri': uri, 'type': section.CONTENT_TYPE, @@ -252,20 +349,52 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): data = server.query(key, method=server._session.post)[0] return cls(server, data, initpath=key) + @classmethod + def create(cls, server, title, section=None, items=None, smart=False, limit=None, + sort=None, filters=None, **kwargs): + """ Create a playlist. + + Parameters: + server (:class:`~plexapi.server.PlexServer`): Server to create the playlist on. + title (str): Title of the playlist. + section (:class:`~plexapi.library.LibrarySection`, str): Smart playlists only, + the library section to create the playlist in. + items (List): Regular playlists only, list of :class:`~plexapi.audio.Audio`, + :class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the playlist. + smart (bool): True to create a smart playlist. Default False. + limit (int): Smart playlists only, limit the number of items in the playlist. + sort (str or list, optional): Smart playlists only, a string of comma separated sort fields + or a list of sort fields in the format ``column:dir``. + See :func:`~plexapi.library.LibrarySection.search` for more info. + filters (dict): Smart playlists only, a dictionary of advanced filters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + **kwargs (dict): Smart playlists only, additional custom filters to apply to the + search results. See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When no items are included to create the playlist. + :class:`plexapi.exceptions.BadRequest`: When mixing media types in the playlist. + + Returns: + :class:`~plexapi.playlist.Playlist`: A new instance of the created Playlist. + """ + if smart: + return cls._createSmart(server, title, section, limit, sort, filters, **kwargs) + else: + return cls._create(server, title, items) + def copyToUser(self, user): - """ Copy playlist to another user account. """ - from plexapi.server import PlexServer - myplex = self._server.myPlexAccount() - user = myplex.user(user) - # Get the token for your machine. - token = user.get_token(self._server.machineIdentifier) - # Login to your server using your friends credentials. - user_server = PlexServer(self._server._baseurl, token) - return self.create(user_server, self.title, self.items()) + """ Copy playlist to another user account. + + Parameters: + user (str): Username, email or user id of the user to copy the playlist to. + """ + userServer = self._server.switchUser(user) + return self.create(server=userServer, title=self.title, items=self.items()) def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None, unwatched=False, title=None): - """ Add current playlist as sync item for specified device. + """ Add the playlist as a sync item for the specified device. See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions. Parameters: @@ -288,9 +417,8 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): :exc:`~plexapi.exceptions.Unsupported`: When playlist content is unsupported. Returns: - :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. + :class:`~plexapi.sync.SyncItem`: A new instance of the created sync item. """ - if not self.allowSync: raise BadRequest('The playlist is not allowed to sync') diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index 4b9c7e88..2ec8c422 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -9,13 +9,14 @@ from plexapi import utils from plexapi.alert import AlertListener from plexapi.base import PlexObject from plexapi.client import PlexClient +from plexapi.collection import Collection from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.library import Hub, Library, Path, File 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, deprecated +from plexapi.utils import deprecated from requests.status_codes import _codes as codes # Need these imports to populate utils.PLEXOBJECTS @@ -38,8 +39,9 @@ class PlexServer(PlexObject): baseurl (str): Base url for to access the Plex Media Server (default: 'http://localhost:32400'). token (str): Required Plex authentication token to access the server. session (requests.Session, optional): Use your own session object if you want to - cache the http responses from PMS - timeout (int): timeout in seconds on initial connect to server (default config.TIMEOUT). + cache the http responses from the server. + timeout (int, optional): Timeout in seconds on initial connection to the server + (default config.TIMEOUT). Attributes: allowCameraUpload (bool): True if server allows camera upload. @@ -105,58 +107,59 @@ class PlexServer(PlexObject): self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token')) self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' self._session = session or requests.Session() + self._timeout = timeout 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) + data = self.query(self.key, timeout=self._timeout) super(PlexServer, self).__init__(self, data, self.key) def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data - self.allowCameraUpload = cast(bool, data.attrib.get('allowCameraUpload')) - self.allowChannelAccess = cast(bool, data.attrib.get('allowChannelAccess')) - self.allowMediaDeletion = cast(bool, data.attrib.get('allowMediaDeletion')) - self.allowSharing = cast(bool, data.attrib.get('allowSharing')) - self.allowSync = cast(bool, data.attrib.get('allowSync')) - self.backgroundProcessing = cast(bool, data.attrib.get('backgroundProcessing')) - self.certificate = cast(bool, data.attrib.get('certificate')) - self.companionProxy = cast(bool, data.attrib.get('companionProxy')) + self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload')) + self.allowChannelAccess = utils.cast(bool, data.attrib.get('allowChannelAccess')) + self.allowMediaDeletion = utils.cast(bool, data.attrib.get('allowMediaDeletion')) + self.allowSharing = utils.cast(bool, data.attrib.get('allowSharing')) + self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) + self.backgroundProcessing = utils.cast(bool, data.attrib.get('backgroundProcessing')) + self.certificate = utils.cast(bool, data.attrib.get('certificate')) + self.companionProxy = utils.cast(bool, data.attrib.get('companionProxy')) self.diagnostics = utils.toList(data.attrib.get('diagnostics')) - self.eventStream = cast(bool, data.attrib.get('eventStream')) + self.eventStream = utils.cast(bool, data.attrib.get('eventStream')) self.friendlyName = data.attrib.get('friendlyName') - self.hubSearch = cast(bool, data.attrib.get('hubSearch')) + self.hubSearch = utils.cast(bool, data.attrib.get('hubSearch')) self.machineIdentifier = data.attrib.get('machineIdentifier') - self.multiuser = cast(bool, data.attrib.get('multiuser')) - self.myPlex = cast(bool, data.attrib.get('myPlex')) + self.multiuser = utils.cast(bool, data.attrib.get('multiuser')) + self.myPlex = utils.cast(bool, data.attrib.get('myPlex')) self.myPlexMappingState = data.attrib.get('myPlexMappingState') self.myPlexSigninState = data.attrib.get('myPlexSigninState') - self.myPlexSubscription = cast(bool, data.attrib.get('myPlexSubscription')) + self.myPlexSubscription = utils.cast(bool, data.attrib.get('myPlexSubscription')) self.myPlexUsername = data.attrib.get('myPlexUsername') self.ownerFeatures = utils.toList(data.attrib.get('ownerFeatures')) - self.photoAutoTag = cast(bool, data.attrib.get('photoAutoTag')) + self.photoAutoTag = utils.cast(bool, data.attrib.get('photoAutoTag')) self.platform = data.attrib.get('platform') self.platformVersion = data.attrib.get('platformVersion') - self.pluginHost = cast(bool, data.attrib.get('pluginHost')) - self.readOnlyLibraries = cast(int, data.attrib.get('readOnlyLibraries')) - self.requestParametersInCookie = cast(bool, data.attrib.get('requestParametersInCookie')) + self.pluginHost = utils.cast(bool, data.attrib.get('pluginHost')) + self.readOnlyLibraries = utils.cast(int, data.attrib.get('readOnlyLibraries')) + self.requestParametersInCookie = utils.cast(bool, data.attrib.get('requestParametersInCookie')) self.streamingBrainVersion = data.attrib.get('streamingBrainVersion') - self.sync = cast(bool, data.attrib.get('sync')) + self.sync = utils.cast(bool, data.attrib.get('sync')) self.transcoderActiveVideoSessions = int(data.attrib.get('transcoderActiveVideoSessions', 0)) - self.transcoderAudio = cast(bool, data.attrib.get('transcoderAudio')) - self.transcoderLyrics = cast(bool, data.attrib.get('transcoderLyrics')) - self.transcoderPhoto = cast(bool, data.attrib.get('transcoderPhoto')) - self.transcoderSubtitles = cast(bool, data.attrib.get('transcoderSubtitles')) - self.transcoderVideo = cast(bool, data.attrib.get('transcoderVideo')) + self.transcoderAudio = utils.cast(bool, data.attrib.get('transcoderAudio')) + self.transcoderLyrics = utils.cast(bool, data.attrib.get('transcoderLyrics')) + self.transcoderPhoto = utils.cast(bool, data.attrib.get('transcoderPhoto')) + self.transcoderSubtitles = utils.cast(bool, data.attrib.get('transcoderSubtitles')) + self.transcoderVideo = utils.cast(bool, data.attrib.get('transcoderVideo')) self.transcoderVideoBitrates = utils.toList(data.attrib.get('transcoderVideoBitrates')) self.transcoderVideoQualities = utils.toList(data.attrib.get('transcoderVideoQualities')) self.transcoderVideoResolutions = utils.toList(data.attrib.get('transcoderVideoResolutions')) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) - self.updater = cast(bool, data.attrib.get('updater')) + self.updater = utils.cast(bool, data.attrib.get('updater')) self.version = data.attrib.get('version') - self.voiceSearch = cast(bool, data.attrib.get('voiceSearch')) + self.voiceSearch = utils.cast(bool, data.attrib.get('voiceSearch')) def _headers(self, **kwargs): """ Returns dict containing base headers for all requests to the server. """ @@ -166,6 +169,9 @@ class PlexServer(PlexObject): headers.update(kwargs) return headers + def _uriRoot(self): + return 'server://%s/com.plexapp.plugins.library' % self.machineIdentifier + @property def library(self): """ Library to browse or search your media. """ @@ -193,6 +199,26 @@ class PlexServer(PlexObject): data = self.query(Account.key) return Account(self, data) + def claim(self, account): + """ Claim the Plex server using a :class:`~plexapi.myplex.MyPlexAccount`. + This will only work with an unclaimed server on localhost or the same subnet. + + Parameters: + account (:class:`~plexapi.myplex.MyPlexAccount`): The account used to + claim the server. + """ + key = '/myplex/claim' + params = {'token': account.claimToken()} + data = self.query(key, method=self._session.post, params=params) + return Account(self, data) + + def unclaim(self): + """ Unclaim the Plex server. This will remove the server from your + :class:`~plexapi.myplex.MyPlexAccount`. + """ + data = self.query(Account.key, method=self._session.delete) + return Account(self, data) + @property def activities(self): """Returns all current PMS activities.""" @@ -209,13 +235,45 @@ class PlexServer(PlexObject): return self.fetchItems(key) def createToken(self, type='delegation', scope='all'): - """Create a temp access token for the server.""" + """ Create a temp access token for the server. """ if not self._token: # Handle unclaimed servers return None q = self.query('/security/token?type=%s&scope=%s' % (type, scope)) return q.attrib.get('token') + def switchUser(self, username, session=None, timeout=None): + """ Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username. + Note: Only the admin account can switch to other users. + + Parameters: + username (str): Username, email or user id of the user to log in to the server. + session (requests.Session, optional): Use your own session object if you want to + cache the http responses from the server. This will default to the same + session as the admin account if no new session is provided. + timeout (int, optional): Timeout in seconds on initial connection to the server. + This will default to the same timeout as the admin account if no new timeout + is provided. + + Example: + + .. code-block:: python + + from plexapi.server import PlexServer + # Login to the Plex server using the admin token + plex = PlexServer('http://plexserver:32400', token='2ffLuB84dqLswk9skLos') + # Login to the same Plex server using a different account + userPlex = plex.switchUser("Username") + + """ + user = self.myPlexAccount().user(username) + userToken = user.get_token(self.machineIdentifier) + if session is None: + session = self._session + if timeout is None: + timeout = self._timeout + return PlexServer(self._baseurl, token=userToken, session=session, timeout=timeout) + def systemAccounts(self): """ Returns a list of :class:`~plexapi.server.SystemAccount` objects this server contains. """ if self._systemAccounts is None: @@ -357,14 +415,68 @@ class PlexServer(PlexObject): raise NotFound('Unknown client name: %s' % name) - def createPlaylist(self, title, items=None, section=None, limit=None, smart=None, **kwargs): + def createCollection(self, title, section, items=None, smart=False, limit=None, + libtype=None, sort=None, filters=None, **kwargs): + """ Creates and returns a new :class:`~plexapi.collection.Collection`. + + Parameters: + title (str): Title of the collection. + section (:class:`~plexapi.library.LibrarySection`, str): The library section to create the collection in. + items (List): Regular collections only, list of :class:`~plexapi.audio.Audio`, + :class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the collection. + smart (bool): True to create a smart collection. Default False. + limit (int): Smart collections only, limit the number of items in the collection. + libtype (str): Smart collections only, the specific type of content to filter + (movie, show, season, episode, artist, album, track, photoalbum, photo, collection). + sort (str or list, optional): Smart collections only, a string of comma separated sort fields + or a list of sort fields in the format ``column:dir``. + See :func:`~plexapi.library.LibrarySection.search` for more info. + filters (dict): Smart collections only, a dictionary of advanced filters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + **kwargs (dict): Smart collections only, additional custom filters to apply to the + search results. See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When no items are included to create the collection. + :class:`plexapi.exceptions.BadRequest`: When mixing media types in the collection. + + Returns: + :class:`~plexapi.collection.Collection`: A new instance of the created Collection. + """ + return Collection.create( + self, title, section, items=items, smart=smart, limit=limit, + libtype=libtype, sort=sort, filters=filters, **kwargs) + + def createPlaylist(self, title, section=None, items=None, smart=False, limit=None, + sort=None, filters=None, **kwargs): """ Creates and returns a new :class:`~plexapi.playlist.Playlist`. Parameters: - title (str): Title of the playlist to be created. - items (list): List of media items to include in the playlist. + title (str): Title of the playlist. + section (:class:`~plexapi.library.LibrarySection`, str): Smart playlists only, + library section to create the playlist in. + items (List): Regular playlists only, list of :class:`~plexapi.audio.Audio`, + :class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the playlist. + smart (bool): True to create a smart playlist. Default False. + limit (int): Smart playlists only, limit the number of items in the playlist. + sort (str or list, optional): Smart playlists only, a string of comma separated sort fields + or a list of sort fields in the format ``column:dir``. + See :func:`~plexapi.library.LibrarySection.search` for more info. + filters (dict): Smart playlists only, a dictionary of advanced filters. + See :func:`~plexapi.library.LibrarySection.search` for more info. + **kwargs (dict): Smart playlists only, additional custom filters to apply to the + search results. See :func:`~plexapi.library.LibrarySection.search` for more info. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When no items are included to create the playlist. + :class:`plexapi.exceptions.BadRequest`: When mixing media types in the playlist. + + Returns: + :class:`~plexapi.playlist.Playlist`: A new instance of the created Playlist. """ - return Playlist.create(self, title, items=items, limit=limit, section=section, smart=smart, **kwargs) + return Playlist.create( + self, title, section=section, items=items, smart=smart, limit=limit, + sort=sort, filters=filters, **kwargs) def createPlayQueue(self, item, **kwargs): """ Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`. @@ -463,11 +575,17 @@ class PlexServer(PlexObject): args['X-Plex-Container-Start'] += args['X-Plex-Container-Size'] return results - def playlists(self): - """ Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """ - # TODO: Add sort and type options? - # /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0 - return self.fetchItems('/playlists') + def playlists(self, playlistType=None): + """ Returns a list of all :class:`~plexapi.playlist.Playlist` objects on the server. + + Parameters: + playlistType (str, optional): The type of playlists to return (audio, video, photo). + Default returns all playlists. + """ + key = '/playlists' + if playlistType: + key = '%s?playlistType=%s' % (key, playlistType) + return self.fetchItems(key) def playlist(self, title): """ Returns the :class:`~plexapi.client.Playlist` that matches the specified title. @@ -489,6 +607,7 @@ class PlexServer(PlexObject): backgroundProcessing = self.fetchItem('/playlists?type=42') return self.fetchItems('%s/items' % backgroundProcessing.key, cls=Optimized) + @deprecated('use "plexapi.media.Optimized.items()" instead') def optimizedItem(self, optimizedID): """ Returns single queued optimized item :class:`~plexapi.media.Video` object. Allows for using optimized item ID to connect back to source item. @@ -735,11 +854,11 @@ class PlexServer(PlexObject): raise BadRequest('Unknown filter: %s=%s' % (key, value)) if key.startswith('at'): try: - value = cast(int, value.timestamp()) + value = utils.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) + value = utils.cast(int, value) elif key == 'accountID': if value == self.myPlexAccount().id: value = 1 # The admin account is accountID=1 @@ -799,7 +918,7 @@ class Account(PlexObject): self.privateAddress = data.attrib.get('privateAddress') self.privatePort = data.attrib.get('privatePort') self.subscriptionFeatures = utils.toList(data.attrib.get('subscriptionFeatures')) - self.subscriptionActive = cast(bool, data.attrib.get('subscriptionActive')) + self.subscriptionActive = utils.cast(bool, data.attrib.get('subscriptionActive')) self.subscriptionState = data.attrib.get('subscriptionState') @@ -809,8 +928,8 @@ class Activity(PlexObject): def _loadData(self, data): self._data = data - self.cancellable = cast(bool, data.attrib.get('cancellable')) - self.progress = cast(int, data.attrib.get('progress')) + self.cancellable = utils.cast(bool, data.attrib.get('cancellable')) + self.progress = utils.cast(int, data.attrib.get('progress')) self.title = data.attrib.get('title') self.subtitle = data.attrib.get('subtitle') self.type = data.attrib.get('type') @@ -849,13 +968,13 @@ class SystemAccount(PlexObject): def _loadData(self, data): self._data = data - self.autoSelectAudio = cast(bool, data.attrib.get('autoSelectAudio')) + self.autoSelectAudio = utils.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.id = utils.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.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode')) self.thumb = data.attrib.get('thumb') # For backwards compatibility self.accountID = self.id @@ -880,7 +999,7 @@ class SystemDevice(PlexObject): self._data = data self.clientIdentifier = data.attrib.get('clientIdentifier') self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) - self.id = cast(int, data.attrib.get('id')) + self.id = utils.cast(int, data.attrib.get('id')) self.key = '/devices/%s' % self.id self.name = data.attrib.get('name') self.platform = data.attrib.get('platform') @@ -904,12 +1023,12 @@ class StatisticsBandwidth(PlexObject): def _loadData(self, data): self._data = data - self.accountID = cast(int, data.attrib.get('accountID')) + self.accountID = utils.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')) + self.bytes = utils.cast(int, data.attrib.get('bytes')) + self.deviceID = utils.cast(int, data.attrib.get('deviceID')) + self.lan = utils.cast(bool, data.attrib.get('lan')) + self.timespan = utils.cast(int, data.attrib.get('timespan')) def __repr__(self): return '<%s>' % ':'.join([p for p in [ @@ -945,11 +1064,11 @@ class StatisticsResources(PlexObject): 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')) + self.hostCpuUtilization = utils.cast(float, data.attrib.get('hostCpuUtilization')) + self.hostMemoryUtilization = utils.cast(float, data.attrib.get('hostMemoryUtilization')) + self.processCpuUtilization = utils.cast(float, data.attrib.get('processCpuUtilization')) + self.processMemoryUtilization = utils.cast(float, data.attrib.get('processMemoryUtilization')) + self.timespan = utils.cast(int, data.attrib.get('timespan')) def __repr__(self): return '<%s>' % ':'.join([p for p in [ diff --git a/lib/plexapi/settings.py b/lib/plexapi/settings.py index 734cc119..30c1c583 100644 --- a/lib/plexapi/settings.py +++ b/lib/plexapi/settings.py @@ -164,10 +164,10 @@ class Preferences(Setting): """ Represents a single Preferences. Attributes: - TAG (str): 'Preferences' + TAG (str): 'Setting' FILTER (str): 'preferences' """ - TAG = 'Preferences' + TAG = 'Setting' FILTER = 'preferences' def _default(self): diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index be07672e..5fe31caa 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -200,9 +200,8 @@ def toDatetime(value, format=None): else: # https://bugs.python.org/issue30684 # And platform support for before epoch seems to be flaky. - # TODO check for others errors too. - if int(value) <= 0: - value = 86400 + # Also limit to max 32-bit integer + value = min(max(int(value), 86400), 2**31 - 1) value = datetime.fromtimestamp(int(value)) return value diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index b984f431..609eaffc 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -6,7 +6,7 @@ from plexapi import library, media, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin -from plexapi.mixins import SplitMergeMixin, UnmatchMatchMixin +from plexapi.mixins import RatingMixin, SplitMergeMixin, UnmatchMatchMixin from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin @@ -22,6 +22,7 @@ class Video(PlexPartialObject): 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/). + lastRatedAt (datetime): Datetime the item was last rated. lastViewedAt (datetime): Datetime the item was last played. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. @@ -35,6 +36,7 @@ class Video(PlexPartialObject): titleSort (str): Title to use when sorting (defaults to title). type (str): 'movie', 'show', 'season', 'episode', or 'clip'. updatedAt (datatime): Datetime the item was updated. + userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars). viewCount (int): Count of times the item was played. """ @@ -47,6 +49,7 @@ class Video(PlexPartialObject): self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') self.key = data.attrib.get('key', '') + self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) self.librarySectionKey = data.attrib.get('librarySectionKey') @@ -60,6 +63,7 @@ class Video(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')) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) @property @@ -72,23 +76,32 @@ class Video(PlexPartialObject): return self._server.url(part, includeToken=True) if part else None def markWatched(self): - """ Mark video as watched. """ + """ Mark the video as palyed. """ key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey self._server.query(key) - self.reload() def markUnwatched(self): - """ Mark video unwatched. """ + """ Mark the video as unplayed. """ key = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey self._server.query(key) - self.reload() - def rate(self, rate): - """ Rate video. """ - key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rate) - - self._server.query(key) - self.reload() + def augmentation(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. + Augmentation returns hub items relating to online media sources + such as Tidal Music "Track from {item}" or "Soundtrack of {item}". + Plex Pass and linked Tidal account are required. + """ + account = self._server.myPlexAccount() + tidalOptOut = next( + (service.value for service in account.onlineMediaSources() + if service.key == 'tv.plex.provider.music'), + None + ) + if account.subscriptionStatus != 'Active' or tidalOptOut == 'opt_out': + raise BadRequest('Requires Plex Pass and Tidal Music enabled.') + data = self._server.query(self.key + '?asyncAugmentMetadata=1') + augmentationKey = data.attrib.get('augmentationKey') + return self.fetchItems(augmentationKey) def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ @@ -248,7 +261,7 @@ class Video(PlexPartialObject): @utils.registerPlexObject -class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, +class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin, CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin): """ Represents a single Movie. @@ -282,7 +295,6 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Split tagline (str): Movie tag line (Back 2 Work; Who says men can't change?). useOriginalTitle (int): Setting that indicates if the original title is used for the movie (-1 = Library default, 0 = No, 1 = Yes). - userRating (float): User rating (2.0; 8.0). viewOffset (int): View offset in milliseconds. writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. year (int): Year movie was released. @@ -320,7 +332,6 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Split self.studio = data.attrib.get('studio') self.tagline = data.attrib.get('tagline') self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) - self.userRating = utils.cast(float, data.attrib.get('userRating')) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.writers = self.findItems(data, media.Writer) self.year = utils.cast(int, data.attrib.get('year')) @@ -335,23 +346,34 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Split """ This does not exist in plex xml response but is added to have a common interface to get the locations of the movie. - Retruns: + Returns: List of file paths where the movie is found on disk. """ return [part.file for part in self.iterParts() if part] + @property + def hasPreviewThumbnails(self): + """ Returns True if any of the media parts has generated preview (BIF) thumbnails. """ + return any(part.hasPreviewThumbnails for media in self.media for part in media.parts) + def _prettyfilename(self): # This is just for compat. return self.title + def reviews(self): + """ Returns a list of :class:`~plexapi.media.Review` objects. """ + data = self._server.query(self._details_key) + return self.findItems(data, media.Review, rtag='Video') + + def extras(self): + """ Returns a list of :class:`~plexapi.video.Extra` objects. """ + data = self._server.query(self._details_key) + return self.findItems(data, Extra, rtag='Extras') + 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) + return self.findItems(data, library.Hub, rtag='Related') def download(self, savepath=None, keep_original_name=False, **kwargs): """ Download video files to specified directory. @@ -381,7 +403,7 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Split @utils.registerPlexObject -class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, +class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin, CollectionMixin, GenreMixin, LabelMixin): """ Represents a single Show (including all seasons and episodes). @@ -428,7 +450,6 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Spl theme (str): URL to theme resource (/library/metadata//theme/). useOriginalTitle (int): Setting that indicates if the original title is used for the show (-1 = Library default, 0 = No, 1 = Yes). - userRating (float): User rating (2.0; 8.0). viewedLeafCount (int): Number of items marked as played in the show view. year (int): Year the show was released. """ @@ -471,7 +492,6 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Spl self.tagline = data.attrib.get('tagline') self.theme = data.attrib.get('theme') self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) - self.userRating = utils.cast(float, data.attrib.get('userRating')) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) @@ -492,21 +512,14 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Spl 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) + return self.findItems(data, library.Hub, rtag='Related') 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 + return next(iter(self.findItems(data, rtag='OnDeck')), None) def season(self, title=None, season=None): """ Returns the season with the specified title or number. @@ -518,7 +531,7 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Spl Raises: :exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing. """ - key = '/library/metadata/%s/children' % self.ratingKey + key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey 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): @@ -585,12 +598,13 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Spl @utils.registerPlexObject -class Season(Video, ArtMixin, PosterMixin): +class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin): """ Represents a single Show Season (including all episodes). Attributes: TAG (str): 'Directory' TYPE (str): 'season' + collections (List<:class:`~plexapi.media.Collection`>): List of collection objects. guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. index (int): Season number. key (str): API URL (/library/metadata/). @@ -599,10 +613,12 @@ class Season(Video, ArtMixin, PosterMixin): parentIndex (int): Plex index number for the show. parentKey (str): API URL of the show (/library/metadata/). parentRatingKey (int): Unique key identifying the show. + parentStudio (str): Studio that created 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. + year (int): Year the season was released. """ TAG = 'Directory' TYPE = 'season' @@ -611,18 +627,21 @@ class Season(Video, ArtMixin, PosterMixin): def _loadData(self, data): """ Load attribute values from Plex XML response. """ Video._loadData(self, data) + self.collections = self.findItems(data, media.Collection) self.guids = self.findItems(data, media.Guid) 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.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) self.parentKey = data.attrib.get('parentKey') self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self.parentStudio = data.attrib.get('parentStudio') self.parentTheme = data.attrib.get('parentTheme') self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) + self.year = utils.cast(int, data.attrib.get('year')) def __iter__(self): for episode in self.episodes(): @@ -642,7 +661,7 @@ class Season(Video, ArtMixin, PosterMixin): @property def seasonNumber(self): - """ Returns season number. """ + """ Returns the season number. """ return self.index def episodes(self, **kwargs): @@ -661,10 +680,14 @@ class Season(Video, ArtMixin, PosterMixin): :exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing. """ key = '/library/metadata/%s/children' % self.ratingKey - if title is not None: + if title is not None and not isinstance(title, int): return self.fetchItem(key, Episode, title__iexact=title) - elif episode is not None: - return self.fetchItem(key, Episode, parentIndex=self.index, index=episode) + elif episode is not None or isinstance(title, int): + if isinstance(title, int): + index = title + else: + index = episode + return self.fetchItem(key, Episode, parentIndex=self.index, index=index) raise BadRequest('Missing argument: title or episode is required') def get(self, title=None, episode=None): @@ -676,10 +699,7 @@ class Season(Video, ArtMixin, PosterMixin): 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 + return next(iter(self.findItems(data, rtag='OnDeck')), None) def show(self): """ Return the season's :class:`~plexapi.video.Show`. """ @@ -713,8 +733,8 @@ class Season(Video, ArtMixin, PosterMixin): @utils.registerPlexObject -class Episode(Video, Playable, ArtMixin, PosterMixin, - DirectorMixin, WriterMixin): +class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin, + CollectionMixin, DirectorMixin, WriterMixin): """ Represents a single Shows Episode. Attributes: @@ -724,6 +744,7 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, audienceRatingImage (str): Key to audience rating image (tmdb://image.rating). 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). directors (List<:class:`~plexapi.media.Director`>): List of director objects. duration (int): Duration of the episode in milliseconds. @@ -745,12 +766,12 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, 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. + parentYear (int): Year the season was released. rating (float): Episode rating (7.9; 9.8; 8.1). skipParent (bool): True if the show's seasons are set to hidden. - userRating (float): User rating (2.0; 8.0). viewOffset (int): View offset in milliseconds. writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. - year (int): Year episode was released. + year (int): Year the episode was released. """ TAG = 'Video' TYPE = 'episode' @@ -765,6 +786,7 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, 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.directors = self.findItems(data, media.Director) self.duration = utils.cast(int, data.attrib.get('duration')) @@ -786,9 +808,9 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') + self.parentYear = utils.cast(int, data.attrib.get('parentYear')) self.rating = utils.cast(float, data.attrib.get('rating')) self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) - self.userRating = utils.cast(float, data.attrib.get('userRating')) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.writers = self.findItems(data, media.Writer) self.year = utils.cast(int, data.attrib.get('year')) @@ -821,30 +843,38 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, """ This does not exist in plex xml response but is added to have a common interface to get the locations of the episode. - Retruns: + Returns: List of file paths where the episode is found on disk. """ return [part.file for part in self.iterParts() if part] + @property + def episodeNumber(self): + """ Returns the episode number. """ + return self.index + @property def seasonNumber(self): - """ Returns the episodes season number. """ + """ Returns the episode's season number. """ if self._seasonNumber is None: self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber return utils.cast(int, self._seasonNumber) @property def seasonEpisode(self): - """ Returns the s00e00 string containing the season and episode. """ - return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.index).zfill(2)) + """ Returns the s00e00 string containing the season and episode numbers. """ + return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.episodeNumber).zfill(2)) @property def hasIntroMarker(self): """ 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) + @property + def hasPreviewThumbnails(self): + """ Returns True if any of the media parts has generated preview (BIF) thumbnails. """ + return any(part.hasPreviewThumbnails for media in self.media for part in media.parts) + def season(self): """" Return the episode's :class:`~plexapi.video.Season`. """ return self.fetchItem(self.parentKey) @@ -876,7 +906,6 @@ class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin): viewOffset (int): View offset in milliseconds. year (int): Year clip was released. """ - TAG = 'Video' TYPE = 'clip' METADATA_TYPE = 'clip' @@ -886,11 +915,13 @@ class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin): Video._loadData(self, data) Playable._loadData(self, data) self._data = data + self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.duration = utils.cast(int, data.attrib.get('duration')) 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.originallyAvailableAt = utils.toDatetime( + data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.skipDetails = utils.cast(int, data.attrib.get('skipDetails')) self.subtype = data.attrib.get('subtype') self.thumbAspectRatio = data.attrib.get('thumbAspectRatio') @@ -902,7 +933,25 @@ class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin): """ This does not exist in plex xml response but is added to have a common interface to get the locations of the clip. - Retruns: + Returns: List of file paths where the clip is found on disk. """ return [part.file for part in self.iterParts() if part] + + def _prettyfilename(self): + return self.title + + +class Extra(Clip): + """ Represents a single Extra (trailer, behindTheScenes, etc). """ + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + super(Extra, self)._loadData(data) + parent = self._parent() + self.librarySectionID = parent.librarySectionID + self.librarySectionKey = parent.librarySectionKey + self.librarySectionTitle = parent.librarySectionTitle + + def _prettyfilename(self): + return '%s (%s)' % (self.title, self.subtype)