Update PlexAPI to 4.6.1

This commit is contained in:
JonnyWong16 2021-06-15 22:12:59 -07:00
parent b0a395ad0b
commit fec17a7344
No known key found for this signature in database
GPG key ID: B1F1F9807184697A
14 changed files with 1726 additions and 649 deletions

View file

@ -15,7 +15,7 @@ CONFIG = PlexConfig(CONFIG_PATH)
# PlexAPI Settings # PlexAPI Settings
PROJECT = 'PlexAPI' PROJECT = 'PlexAPI'
VERSION = '4.5.2' VERSION = '4.6.1'
TIMEOUT = CONFIG.get('plexapi.timeout', 30, int) TIMEOUT = CONFIG.get('plexapi.timeout', 30, int)
X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, 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) X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool)

View file

@ -5,7 +5,7 @@ from plexapi import library, media, utils
from plexapi.base import Playable, PlexPartialObject from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest from plexapi.exceptions import BadRequest
from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin 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 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). guid (str): Plex GUID for the artist, album, or track (plex://artist/5d07bcb0403c64029053ac4c).
index (int): Plex index number (often the track number). index (int): Plex index number (often the track number).
key (str): API URL (/library/metadata/<ratingkey>). key (str): API URL (/library/metadata/<ratingkey>).
lastRatedAt (datetime): Datetime the item was last rated.
lastViewedAt (datetime): Datetime the item was last played. lastViewedAt (datetime): Datetime the item was last played.
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
@ -35,7 +36,7 @@ class Audio(PlexPartialObject):
titleSort (str): Title to use when sorting (defaults to title). titleSort (str): Title to use when sorting (defaults to title).
type (str): 'artist', 'album', or 'track'. type (str): 'artist', 'album', or 'track'.
updatedAt (datatime): Datetime the item was updated. 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. viewCount (int): Count of times the item was played.
""" """
@ -51,6 +52,7 @@ class Audio(PlexPartialObject):
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '') self.key = data.attrib.get('key', '')
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionKey = data.attrib.get('librarySectionKey')
@ -65,7 +67,7 @@ class Audio(PlexPartialObject):
self.titleSort = data.attrib.get('titleSort', self.title) self.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) 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)) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
def url(self, part): def url(self, part):
@ -114,7 +116,7 @@ class Audio(PlexPartialObject):
@utils.registerPlexObject @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): CollectionMixin, CountryMixin, GenreMixin, MoodMixin, SimilarArtistMixin, StyleMixin):
""" Represents a single Artist. """ Represents a single Artist.
@ -153,11 +155,7 @@ class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, SplitMergeMixi
def hubs(self): def hubs(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects. """ """ Returns a list of :class:`~plexapi.library.Hub` objects. """
data = self._server.query(self._details_key) data = self._server.query(self._details_key)
directory = data.find('Directory') return self.findItems(data, library.Hub, rtag='Related')
if directory:
related = directory.find('Related')
if related:
return self.findItems(related, library.Hub)
def album(self, title): def album(self, title):
""" Returns the :class:`~plexapi.audio.Album` that matches the specified 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 @utils.registerPlexObject
class Album(Audio, ArtMixin, PosterMixin, UnmatchMatchMixin, class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin,
CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin): CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin):
""" Represents a single Album. """ Represents a single Album.
@ -328,13 +326,15 @@ class Album(Audio, ArtMixin, PosterMixin, UnmatchMatchMixin,
@utils.registerPlexObject @utils.registerPlexObject
class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, MoodMixin): class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin,
CollectionMixin, MoodMixin):
""" Represents a single Track. """ Represents a single Track.
Attributes: Attributes:
TAG (str): 'Directory' TAG (str): 'Directory'
TYPE (str): 'track' TYPE (str): 'track'
chapterSource (str): Unknown chapterSource (str): Unknown
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
duration (int): Length of the track in milliseconds. duration (int): Length of the track in milliseconds.
grandparentArt (str): URL to album artist artwork (/library/metadata/<grandparentRatingKey>/art/<artid>). grandparentArt (str): URL to album artist artwork (/library/metadata/<grandparentRatingKey>/art/<artid>).
grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c). grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c).
@ -344,7 +344,7 @@ class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, MoodMixin):
(/library/metadata/<grandparentRatingKey>/thumb/<thumbid>). (/library/metadata/<grandparentRatingKey>/thumb/<thumbid>).
grandparentTitle (str): Name of the album artist for the track. grandparentTitle (str): Name of the album artist for the track.
media (List<:class:`~plexapi.media.Media`>): List of media objects. 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). parentGuid (str): Plex GUID for the album (plex://album/5d07cd8e403c640290f180f9).
parentIndex (int): Album index. parentIndex (int): Album index.
parentKey (str): API URL of the album (/library/metadata/<parentRatingKey>). parentKey (str): API URL of the album (/library/metadata/<parentRatingKey>).
@ -364,6 +364,7 @@ class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, MoodMixin):
Audio._loadData(self, data) Audio._loadData(self, data)
Playable._loadData(self, data) Playable._loadData(self, data)
self.chapterSource = data.attrib.get('chapterSource') self.chapterSource = data.attrib.get('chapterSource')
self.collections = self.findItems(data, media.Collection)
self.duration = utils.cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentArt = data.attrib.get('grandparentArt')
self.grandparentGuid = data.attrib.get('grandparentGuid') 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 """ This does not exist in plex xml response but is added to have a common
interface to get the locations of the track. interface to get the locations of the track.
Retruns: Returns:
List<str> of file paths where the track is found on disk. List<str> of file paths where the track is found on disk.
""" """
return [part.file for part in self.iterParts() if part] return [part.file for part in self.iterParts() if part]
@property
def trackNumber(self):
""" Returns the track number. """
return self.index
def _defaultSyncTitle(self): def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """ """ Returns str, default title for a new syncItem. """
return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title) return '%s - %s - %s' % (self.grandparentTitle, self.parentTitle, self.title)

View file

@ -2,13 +2,14 @@
import re import re
import weakref import weakref
from urllib.parse import quote_plus, urlencode from urllib.parse import quote_plus, urlencode
from xml.etree import ElementTree
from plexapi import log, utils from plexapi import log, utils
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
from plexapi.utils import tag_plural, tag_helper
DONT_RELOAD_FOR_KEYS = {'key', 'session'} USER_DONT_RELOAD_FOR_KEYS = set()
DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'} _DONT_RELOAD_FOR_KEYS = {'key', 'session'}
_DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'}
OPERATORS = { OPERATORS = {
'exact': lambda v, q: v == q, 'exact': lambda v, q: v == q,
'iexact': lambda v, q: v.lower() == q.lower(), 'iexact': lambda v, q: v.lower() == q.lower(),
@ -47,11 +48,12 @@ class PlexObject(object):
self._server = server self._server = server
self._data = data self._data = data
self._initpath = initpath or self.key 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 self._details_key = None
if data is not None: if data is not None:
self._loadData(data) self._loadData(data)
self._details_key = self._buildDetailsKey() self._details_key = self._buildDetailsKey()
self._autoReload = False
def __repr__(self): def __repr__(self):
uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri')) uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri'))
@ -60,10 +62,12 @@ class PlexObject(object):
def __setattr__(self, attr, value): def __setattr__(self, attr, value):
# Don't overwrite session specific attr with [] # 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, []) 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 self.__dict__[attr] = value
def _clean(self, value): def _clean(self, value):
@ -130,6 +134,19 @@ class PlexObject(object):
return True return True
return False 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): def fetchItem(self, ekey, cls=None, **kwargs):
""" Load the specified key to find and build the first item with the """ 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 specified tag and attrs. If no tag or attrs are specified then
@ -249,7 +266,7 @@ class PlexObject(object):
item.librarySectionID = librarySectionID item.librarySectionID = librarySectionID
return items 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 """ 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 and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used. on how this is used.
@ -259,6 +276,9 @@ class PlexObject(object):
kwargs['etag'] = cls.TAG kwargs['etag'] = cls.TAG
if cls and cls.TYPE and 'type' not in kwargs: if cls and cls.TYPE and 'type' not in kwargs:
kwargs['type'] = cls.TYPE 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 # loop through all data elements to find matches
items = [] items = []
for elem in data: for elem in data:
@ -275,9 +295,12 @@ class PlexObject(object):
if value is not None: if value is not None:
return value return value
def listAttrs(self, data, attr, **kwargs): def listAttrs(self, data, attr, rtag=None, **kwargs):
""" Return a list of values from matching attribute. """ """ Return a list of values from matching attribute. """
results = [] results = []
# rtag to iter on a specific root tag
if rtag:
data = next(data.iter(rtag), [])
for elem in data: for elem in data:
kwargs['%s__exists' % attr] = True kwargs['%s__exists' % attr] = True
if self._checkAttrs(elem, **kwargs): if self._checkAttrs(elem, **kwargs):
@ -315,13 +338,19 @@ class PlexObject(object):
movie.isFullObject() # Returns True 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 details_key = self._buildDetailsKey(**kwargs) if kwargs else self._details_key
key = key or details_key or self.key key = key or details_key or self.key
if not key: if not key:
raise Unsupported('Cannot reload an object not built from a URL.') raise Unsupported('Cannot reload an object not built from a URL.')
self._initpath = key self._initpath = key
data = self._server.query(key) data = self._server.query(key)
self._autoReload = _autoReload
self._loadData(data[0]) self._loadData(data[0])
self._autoReload = False
return self return self
def _checkAttrs(self, elem, **kwargs): def _checkAttrs(self, elem, **kwargs):
@ -427,8 +456,9 @@ class PlexPartialObject(PlexObject):
# Dragons inside.. :-/ # Dragons inside.. :-/
value = super(PlexPartialObject, self).__getattribute__(attr) value = super(PlexPartialObject, self).__getattribute__(attr)
# Check a few cases where we dont want to reload # Check a few cases where we dont want to reload
if attr in DONT_RELOAD_FOR_KEYS: return value if attr in _DONT_RELOAD_FOR_KEYS: return value
if attr in DONT_OVERWRITE_SESSION_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 attr.startswith('_'): return value
if value not in (None, []): return value if value not in (None, []): return value
if self.isFullObject(): return value if self.isFullObject(): return value
@ -438,7 +468,7 @@ class PlexPartialObject(PlexObject):
objname = "%s '%s'" % (clsname, title) if title else clsname objname = "%s '%s'" % (clsname, title) if title else clsname
log.debug("Reloading %s for attr '%s'", objname, attr) log.debug("Reloading %s for attr '%s'", objname, attr)
# Reload and return the value # Reload and return the value
self.reload() self._reload(_autoReload=True)
return super(PlexPartialObject, self).__getattribute__(attr) return super(PlexPartialObject, self).__getattribute__(attr)
def analyze(self): def analyze(self):
@ -464,7 +494,7 @@ class PlexPartialObject(PlexObject):
self._server.query(key, method=self._server._session.put) self._server.query(key, method=self._server._session.put)
def isFullObject(self): 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 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 search result for a movie often only contain a portion of the attributes a full
object (main url) for that movie would contain. object (main url) for that movie would contain.
@ -507,9 +537,9 @@ class PlexPartialObject(PlexObject):
""" """
if not isinstance(items, list): if not isinstance(items, list):
items = [items] 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] 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) self.edit(**tag_edits)
def refresh(self): def refresh(self):
@ -594,7 +624,7 @@ class Playable(object):
Raises: Raises:
:exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL. :exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL.
""" """
if self.TYPE not in ('movie', 'episode', 'track'): if self.TYPE not in ('movie', 'episode', 'track', 'clip'):
raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE) raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
mvb = params.get('maxVideoBitrate') mvb = params.get('maxVideoBitrate')
vr = params.get('videoResolution', '') 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, key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey,
time, state) time, state)
self._server.query(key) self._server.query(key)
self.reload() self._reload(_autoReload=True)
def updateTimeline(self, time, state='stopped', duration=None): def updateTimeline(self, time, state='stopped', duration=None):
""" Set the timeline progress for this video. """ 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 = '/:/timeline?ratingKey=%s&key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s%s'
key %= (self.ratingKey, self.key, time, state, durationStr) key %= (self.ratingKey, self.key, time, state, durationStr)
self._server.query(key) 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/<augmentationKey>).
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'))

View file

@ -1,15 +1,18 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from urllib.parse import quote_plus
from plexapi import media, utils from plexapi import media, utils
from plexapi.base import PlexPartialObject from plexapi.base import PlexPartialObject
from plexapi.exceptions import BadRequest from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.mixins import ArtMixin, PosterMixin from plexapi.library import LibrarySection
from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin
from plexapi.mixins import LabelMixin from plexapi.mixins import LabelMixin
from plexapi.settings import Setting from plexapi.playqueue import PlayQueue
from plexapi.utils import deprecated from plexapi.utils import deprecated
@utils.registerPlexObject @utils.registerPlexObject
class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin): class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin):
""" Represents a single Collection. """ Represents a single Collection.
Attributes: Attributes:
@ -29,6 +32,7 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin):
index (int): Plex index number for the collection. index (int): Plex index number for the collection.
key (str): API URL (/library/metadata/<ratingkey>). key (str): API URL (/library/metadata/<ratingkey>).
labels (List<:class:`~plexapi.media.Label`>): List of label objects. 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. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. 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). titleSort (str): Title to use when sorting (defaults to title).
type (str): 'collection' type (str): 'collection'
updatedAt (datatime): Datetime the collection was updated. updatedAt (datatime): Datetime the collection was updated.
userRating (float): Rating of the collection (0.0 - 10.0) equaling (0 stars - 5 stars).
""" """
TAG = 'Directory' TAG = 'Directory'
TYPE = 'collection' TYPE = 'collection'
def _loadData(self, data): def _loadData(self, data):
self._data = data
self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art') self.art = data.attrib.get('art')
self.artBlurHash = data.attrib.get('artBlurHash') 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.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label) 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.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle') 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.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) 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 @property
@deprecated('use "items" instead', stacklevel=3) @deprecated('use "items" instead', stacklevel=3)
def children(self): def children(self):
return self.items() 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): def item(self, title):
""" Returns the item in the collection that matches the specified title. """ Returns the item in the collection that matches the specified title.
Parameters: Parameters:
title (str): Title of the item to return. 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 for item in self.items():
return self.fetchItem(key, title__iexact=title) if item.title.lower() == title.lower():
return item
raise NotFound('Item with title "%s" not found in the collection' % title)
def items(self): def items(self):
""" Returns a list of all items in the collection. """ """ Returns a list of all items in the collection. """
key = '/library/metadata/%s/children' % self.ratingKey if self._items is None:
return self.fetchItems(key) key = '%s/children' % self.key
items = self.fetchItems(key)
self._items = items
return self._items
def get(self, title): def get(self, title):
""" Alias to :func:`~plexapi.library.Collection.item`. """ """ Alias to :func:`~plexapi.library.Collection.item`. """
return self.item(title) 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): def modeUpdate(self, mode=None):
""" Update Collection Mode """ Update the collection mode advanced setting.
Parameters: Parameters:
mode: default (Library default) mode (str): One of the following values:
hide (Hide Collection) "default" (Library default),
hideItems (Hide Items in this Collection) "hide" (Hide Collection),
showItems (Show this Collection and its Items) "hideItems" (Hide Items in this Collection),
"showItems" (Show this Collection and its Items)
Example: Example:
collection = 'plexapi.library.Collections' .. code-block:: python
collection.updateMode(mode="hide")
collection.updateMode(mode="hide")
""" """
mode_dict = {'default': -1, mode_dict = {
'hide': 0, 'default': -1,
'hideItems': 1, 'hide': 0,
'showItems': 2} 'hideItems': 1,
'showItems': 2
}
key = mode_dict.get(mode) key = mode_dict.get(mode)
if key is None: if key is None:
raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict))) raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict)))
part = '/library/metadata/%s/prefs?collectionMode=%s' % (self.ratingKey, key) self.editAdvanced(collectionMode=key)
return self._server.query(part, method=self._server._session.put)
def sortUpdate(self, sort=None): def sortUpdate(self, sort=None):
""" Update Collection Sorting """ Update the collection order advanced setting.
Parameters: Parameters:
sort: realease (Order Collection by realease dates) sort (str): One of the following values:
alpha (Order Collection alphabetically) "realease" (Order Collection by realease dates),
custom (Custom collection order) "alpha" (Order Collection alphabetically),
"custom" (Custom collection order)
Example: Example:
colleciton = 'plexapi.library.Collections' .. code-block:: python
collection.updateSort(mode="alpha")
collection.updateSort(mode="alpha")
""" """
sort_dict = {'release': 0, sort_dict = {
'alpha': 1, 'release': 0,
'custom': 2} 'alpha': 1,
'custom': 2
}
key = sort_dict.get(sort) key = sort_dict.get(sort)
if key is None: if key is None:
raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict))) raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict)))
part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key) self.editAdvanced(collectionSort=key)
return self._server.query(part, method=self._server._session.put)
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)

View file

@ -219,7 +219,7 @@ class Library(PlexObject):
**Show Preferences** **Show Preferences**
* **agent** (str): com.plexapp.agents.none, com.plexapp.agents.thetvdb, com.plexapp.agents.themoviedb, * **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. * **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true.
* **episodeSort** (int): Episode order. Default -1 Possible options: 0:Oldest first, 1:Newest first. * **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. * **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(): for settingID, value in kwargs.items():
try: try:
enums = idEnums.get(settingID) enums = idEnums[settingID]
enumValues = [int(x) for x in enums] except KeyError:
except TypeError:
raise NotFound('%s not found in %s' % (value, list(idEnums.keys()))) raise NotFound('%s not found in %s' % (value, list(idEnums.keys())))
if value in enumValues: if value in enums:
data[key % settingID] = value data[key % settingID] = value
else: else:
raise NotFound('%s not found in %s' % (value, enums)) raise NotFound('%s not found in %s' % (value, enums))
@ -538,13 +537,16 @@ class LibrarySection(PlexObject):
key = '/library/sections/%s/onDeck' % self.key key = '/library/sections/%s/onDeck' % self.key
return self.fetchItems(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. """ Returns a list of media items recently added from this library section.
Parameters: Parameters:
maxresults (int): Max number of items to return (default 50). 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): def firstCharacter(self):
key = '/library/sections/%s/firstCharacter' % self.key 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 """ Retrieves and caches the list of :class:`~plexapi.library.FilteringType` and
list of :class:`~plexapi.library.FilteringFieldType` for this library section. 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) data = self._server.query(key)
meta = data.find('Meta') self._filterTypes = self.findItems(data, FilteringType, rtag='Meta')
if meta: self._fieldTypes = self.findItems(data, FilteringFieldType, rtag='Meta')
self._filterTypes = self.findItems(meta, FilteringType)
self._fieldTypes = self.findItems(meta, FilteringFieldType) 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): def filterTypes(self):
""" Returns a list of available :class:`~plexapi.library.FilteringType` for this library section. """ """ Returns a list of available :class:`~plexapi.library.FilteringType` for this library section. """
@ -614,7 +622,7 @@ class LibrarySection(PlexObject):
Parameters: Parameters:
libtype (str, optional): The library type to filter (movie, show, season, episode, libtype (str, optional): The library type to filter (movie, show, season, episode,
artist, album, track, photoalbum, photo). artist, album, track, photoalbum, photo, collection).
Raises: Raises:
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype for this library. :exc:`~plexapi.exceptions.NotFound`: Unknown libtype for this library.
@ -659,7 +667,7 @@ class LibrarySection(PlexObject):
Parameters: Parameters:
libtype (str, optional): The library type to filter (movie, show, season, episode, libtype (str, optional): The library type to filter (movie, show, season, episode,
artist, album, track, photoalbum, photo). artist, album, track, photoalbum, photo, collection).
Example: Example:
@ -678,7 +686,7 @@ class LibrarySection(PlexObject):
Parameters: Parameters:
libtype (str, optional): The library type to filter (movie, show, season, episode, libtype (str, optional): The library type to filter (movie, show, season, episode,
artist, album, track, photoalbum, photo). artist, album, track, photoalbum, photo, collection).
Example: Example:
@ -697,7 +705,7 @@ class LibrarySection(PlexObject):
Parameters: Parameters:
libtype (str, optional): The library type to filter (movie, show, season, episode, libtype (str, optional): The library type to filter (movie, show, season, episode,
artist, album, track, photoalbum, photo). artist, album, track, photoalbum, photo, collection).
Example: Example:
@ -740,7 +748,7 @@ class LibrarySection(PlexObject):
field (str): :class:`~plexapi.library.FilteringFilter` object, field (str): :class:`~plexapi.library.FilteringFilter` object,
or the name of the field (genre, year, contentRating, etc.). or the name of the field (genre, year, contentRating, etc.).
libtype (str, optional): The library type to filter (movie, show, season, episode, libtype (str, optional): The library type to filter (movie, show, season, episode,
artist, album, track, photoalbum, photo). artist, album, track, photoalbum, photo, collection).
Raises: Raises:
:exc:`~plexapi.exceptions.BadRequest`: Invalid filter field. :exc:`~plexapi.exceptions.BadRequest`: Invalid filter field.
@ -783,11 +791,11 @@ class LibrarySection(PlexObject):
libtype = _libtype or libtype or self.TYPE libtype = _libtype or libtype or self.TYPE
try: 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: except StopIteration:
for filterType in reversed(self.filterTypes()): for filterType in reversed(self.filterTypes()):
if filterType.type != libtype: 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: if filterField:
break break
else: else:
@ -854,7 +862,7 @@ class LibrarySection(PlexObject):
elif fieldType.type == 'date': elif fieldType.type == 'date':
value = self._validateFieldValueDate(value) value = self._validateFieldValueDate(value)
elif fieldType.type == 'integer': elif fieldType.type == 'integer':
value = int(value) value = float(value) if '.' in str(value) else int(value)
elif fieldType.type == 'string': elif fieldType.type == 'string':
value = str(value) value = str(value)
elif fieldType.type in choiceTypes: elif fieldType.type in choiceTypes:
@ -880,6 +888,19 @@ class LibrarySection(PlexObject):
else: else:
return int(utils.toDatetime(value, '%Y-%m-%d').timestamp()) 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): def _validateSortField(self, sort, libtype=None):
""" Validates a filter sort field is available for the library. """ Validates a filter sort field is available for the library.
Returns the validated sort field string. Returns the validated sort field string.
@ -891,19 +912,19 @@ class LibrarySection(PlexObject):
libtype = _libtype or libtype or self.TYPE libtype = _libtype or libtype or self.TYPE
try: 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: except StopIteration:
availableSorts = [f.key for f in self.listSorts(libtype)] availableSorts = [f.key for f in self.listSorts(libtype)]
raise NotFound('Unknown sort field "%s" for libtype "%s". ' raise NotFound('Unknown sort field "%s" for libtype "%s". '
'Available sort fields: %s' 'Available sort fields: %s'
% (sortField, libtype, availableSorts)) from None % (sortField, libtype, availableSorts)) from None
sortField = filterSort.key sortField = libtype + '.' + filterSort.key
if not sortDir: if not sortDir:
sortDir = filterSort.defaultDirection sortDir = filterSort.defaultDirection
availableDirections = ['asc', 'desc'] availableDirections = ['asc', 'desc', 'nullsLast']
if sortDir not in availableDirections: if sortDir not in availableDirections:
raise NotFound('Unknown sort direction "%s". ' raise NotFound('Unknown sort direction "%s". '
'Available sort directions: %s' 'Available sort directions: %s'
@ -911,28 +932,94 @@ class LibrarySection(PlexObject):
return '%s:%s' % (sortField, sortDir) 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/<sectionKey>/all?<params>``).
"""
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): def hubSearch(self, query, mediatype=None, limit=None):
""" Returns the hub search results for this library. See :func:`plexapi.server.PlexServer.search` """ Returns the hub search results for this library. See :func:`plexapi.server.PlexServer.search`
for details and parameters. for details and parameters.
""" """
return self._server.search(query, mediatype, limit, sectionId=self.key) return self._server.search(query, mediatype, limit, sectionId=self.key)
def search(self, title=None, sort=None, maxresults=None, def search(self, title=None, sort=None, maxresults=None, libtype=None,
libtype=None, container_start=0, container_size=X_PLEX_CONTAINER_SIZE, **kwargs): 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 """ Search the library. The http requests will be batched in container_size. If you are only looking for the
first <num> results, it would be wise to set the maxresults option to that amount so the search doesn't iterate first <num> 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. over all results on the server.
Parameters: Parameters:
title (str, optional): General string query to search for. Partial string matches are allowed. 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. See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields.
maxresults (int, optional): Only return the specified number of results. maxresults (int, optional): Only return the specified number of results.
libtype (str, optional): Return results of a specific type (movie, show, season, episode, 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 artist, album, track, photoalbum, photo, collection) (e.g. ``libtype='episode'`` will only
:class:`~plexapi.video.Episode` objects) return :class:`~plexapi.video.Episode` objects)
container_start (int, optional): Default 0. container_start (int, optional): Default 0.
container_size (int, optional): Default X_PLEX_CONTAINER_SIZE in your config file. 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. **kwargs (dict): Additional custom filters to apply to the search results.
See the details below for more info. 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 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 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 ``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: Examples:
.. code-block:: python .. code-block:: python
library.search(**{"show.collection": "Documentary", "episode.inProgress": True}) library.search(filters={"show.collection": "Documentary", "episode.inProgress": True})
library.search(**{"artist.genre": "pop", "album.decade": 2000}) library.search(filters={"artist.genre": "pop", "album.decade": 2000})
# The following three options are identical and will return Episode objects # The following three options are identical and will return Episode objects
showLibrary.search(title="Winter is Coming", libtype='episode') 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") showLibrary.searchEpisodes(title="Winter is Coming")
# The following will search for the episode title but return Show objects # 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 # The following will fallback to episode.unwatched
showLibrary.search(unwatched=True) showLibrary.search(unwatched=True)
@ -1078,27 +1165,55 @@ class LibrarySection(PlexObject):
* ``=``: ``is`` * ``=``: ``is``
Operators cannot be included directly in the function parameters so the ``**kwargs`` Operators cannot be included directly in the function parameters so the filters
must be provided as a dictionary. The trailing ``=`` on the operator may be excluded. must be provided as a filters dictionary. The trailing ``=`` on the operator may be excluded.
Examples: Examples:
.. code-block:: python .. code-block:: python
# Genre is horror AND thriller # Genre is horror AND thriller
library.search(**{"genre&": ["horror", "thriller"]}) library.search(filters={"genre&": ["horror", "thriller"]})
# Director is not Steven Spielberg # 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 # 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 # 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 # 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** **Using PlexAPI Operators**
@ -1120,28 +1235,8 @@ class LibrarySection(PlexObject):
library.search(genre="holiday", viewCount__gte=3) library.search(genre="holiday", viewCount__gte=3)
""" """
# cleanup the core arguments key, kwargs = self._buildSearchKey(
args = {} title=title, sort=sort, libtype=libtype, limit=limit, filters=filters, returnKwargs=True, **kwargs)
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)
return self._search(key, maxresults, container_start, container_size, **kwargs) return self._search(key, maxresults, container_start, container_size, **kwargs)
def _search(self, 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) container_size=container_size, **kwargs)
if not len(subresults): if not len(subresults):
if offset > self._totalViewSize: 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) results.extend(subresults)
@ -1239,15 +1334,6 @@ class LibrarySection(PlexObject):
if not self.allowSync: if not self.allowSync:
raise BadRequest('The requested library is not allowed to sync') 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() myplex = self._server.myPlexAccount()
sync_item = SyncItem(self._server, None) sync_item = SyncItem(self._server, None)
sync_item.title = title if title else self.title sync_item.title = title if title else self.title
@ -1256,10 +1342,7 @@ class LibrarySection(PlexObject):
sync_item.metadataType = self.METADATA_TYPE sync_item.metadataType = self.METADATA_TYPE
sync_item.machineIdentifier = self._server.machineIdentifier sync_item.machineIdentifier = self._server.machineIdentifier
joined_args = utils.joinArgs(args).lstrip('?') key = self._buildSearchKey(title=title, sort=sort, libtype=libtype, **kwargs)
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)
sync_item.location = 'library://%s/directory/%s' % (self.uuid, quote_plus(key)) sync_item.location = 'library://%s/directory/%s' % (self.uuid, quote_plus(key))
sync_item.policy = policy sync_item.policy = policy
@ -1275,9 +1358,24 @@ class LibrarySection(PlexObject):
""" """
return self._server.history(maxresults=maxresults, mindate=mindate, librarySectionID=self.key, accountID=1) return self._server.history(maxresults=maxresults, mindate=mindate, librarySectionID=self.key, accountID=1)
@deprecated('use "collections" (plural) instead') def createCollection(self, title, items=None, smart=False, limit=None,
def collection(self, **kwargs): libtype=None, sort=None, filters=None, **kwargs):
return self.collections() """ 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): def collections(self, **kwargs):
""" Returns a list of collections from this library section. """ Returns a list of collections from this library section.
@ -1285,6 +1383,25 @@ class LibrarySection(PlexObject):
""" """
return self.search(libtype='collection', **kwargs) 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): def playlists(self, **kwargs):
""" Returns a list of playlists from this library section. """ """ Returns a list of playlists from this library section. """
key = '/playlists?type=15&playlistType=%s&sectionID=%s' % (self.CONTENT_TYPE, self.key) key = '/playlists?type=15&playlistType=%s&sectionID=%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. """ """ Search for a movie. See :func:`~plexapi.library.LibrarySection.search` for usage. """
return self.search(libtype='movie', **kwargs) 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): def sync(self, videoQuality, limit=None, unwatched=False, **kwargs):
""" Add current Movie library section as sync item for specified device. """ 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 See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and
@ -1358,7 +1483,6 @@ class ShowSection(LibrarySection):
TAG (str): 'Directory' TAG (str): 'Directory'
TYPE (str): 'show' TYPE (str): 'show'
""" """
TAG = 'Directory' TAG = 'Directory'
TYPE = 'show' TYPE = 'show'
METADATA_TYPE = 'episode' METADATA_TYPE = 'episode'
@ -1376,13 +1500,29 @@ class ShowSection(LibrarySection):
""" Search for an episode. See :func:`~plexapi.library.LibrarySection.search` for usage. """ """ Search for an episode. See :func:`~plexapi.library.LibrarySection.search` for usage. """
return self.search(libtype='episode', **kwargs) 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. """ Returns a list of recently added episodes from this library section.
Parameters: Parameters:
maxresults (int): Max number of items to return (default 50). 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): def sync(self, videoQuality, limit=None, unwatched=False, **kwargs):
""" Add current Show library section as sync item for specified device. """ Add current Show library section as sync item for specified device.
@ -1429,9 +1569,8 @@ class MusicSection(LibrarySection):
""" """
TAG = 'Directory' TAG = 'Directory'
TYPE = 'artist' TYPE = 'artist'
CONTENT_TYPE = 'audio'
METADATA_TYPE = 'track' METADATA_TYPE = 'track'
CONTENT_TYPE = 'audio'
def albums(self): def albums(self):
""" Returns a list of :class:`~plexapi.audio.Album` objects in this section. """ """ 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. """ """ Search for a track. See :func:`~plexapi.library.LibrarySection.search` for usage. """
return self.search(libtype='track', **kwargs) 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): def sync(self, bitrate, limit=None, **kwargs):
""" Add current Music library section as sync item for specified device. """ 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 See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and
@ -1499,8 +1662,8 @@ class PhotoSection(LibrarySection):
""" """
TAG = 'Directory' TAG = 'Directory'
TYPE = 'photo' TYPE = 'photo'
CONTENT_TYPE = 'photo'
METADATA_TYPE = 'photo' METADATA_TYPE = 'photo'
CONTENT_TYPE = 'photo'
def all(self, libtype=None, **kwargs): def all(self, libtype=None, **kwargs):
""" Returns a list of all items from this library section. """ 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.') raise NotImplementedError('Collections are not available for a Photo library.')
def searchAlbums(self, title, **kwargs): 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) return self.search(libtype='photoalbum', title=title, **kwargs)
def searchPhotos(self, title, **kwargs): def searchPhotos(self, title, **kwargs):
""" Search for a photo. See :func:`~plexapi.library.LibrarySection.search` for usage. """ """ Search for a photo. See :func:`~plexapi.library.LibrarySection.search` for usage. """
return self.search(libtype='photo', title=title, **kwargs) 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): def sync(self, resolution, limit=None, **kwargs):
""" Add current Music library section as sync item for specified device. """ 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 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.tagValue = utils.cast(int, data.attrib.get('tagValue'))
self.thumb = data.attrib.get('thumb') 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 @utils.registerPlexObject
class Tag(HubMediaTag): class Tag(HubMediaTag):
@ -1822,6 +2000,111 @@ class FilteringType(PlexObject):
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.type = data.attrib.get('type') 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 = ('<Sort defaultDirection="%s" descKey="%s:desc" key="%s" title="%s" />'
% (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 = ('<Field key="%s%s" title="%s" type="%s"/>'
% (prefix, field, fieldTitle, fieldType))
manualFields.append(self._manuallyLoadXML(fieldXML, FilteringField))
return manualFields
class FilteringFilter(PlexObject): class FilteringFilter(PlexObject):
""" Represents a single Filter object for a :class:`~plexapi.library.FilteringType`. """ Represents a single Filter object for a :class:`~plexapi.library.FilteringType`.
@ -1850,6 +2133,9 @@ class FilteringSort(PlexObject):
Attributes: Attributes:
TAG (str): 'Sort' 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. defaultDirection (str): The default sorting direction.
descKey (str): The URL key for sorting with desc. descKey (str): The URL key for sorting with desc.
firstCharacterKey (str): API URL path for first character endpoint. firstCharacterKey (str): API URL path for first character endpoint.
@ -1861,6 +2147,9 @@ class FilteringSort(PlexObject):
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
self._data = data 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.defaultDirection = data.attrib.get('defaultDirection')
self.descKey = data.attrib.get('descKey') self.descKey = data.attrib.get('descKey')
self.firstCharacterKey = data.attrib.get('firstCharacterKey') self.firstCharacterKey = data.attrib.get('firstCharacterKey')

View file

@ -6,7 +6,6 @@ from urllib.parse import quote_plus
from plexapi import log, settings, utils from plexapi import log, settings, utils
from plexapi.base import PlexObject from plexapi.base import PlexObject
from plexapi.exceptions import BadRequest from plexapi.exceptions import BadRequest
from plexapi.utils import cast
@utils.registerPlexObject @utils.registerPlexObject
@ -51,31 +50,31 @@ class Media(PlexObject):
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
self._data = data self._data = data
self.aspectRatio = cast(float, data.attrib.get('aspectRatio')) self.aspectRatio = utils.cast(float, data.attrib.get('aspectRatio'))
self.audioChannels = cast(int, data.attrib.get('audioChannels')) self.audioChannels = utils.cast(int, data.attrib.get('audioChannels'))
self.audioCodec = data.attrib.get('audioCodec') self.audioCodec = data.attrib.get('audioCodec')
self.audioProfile = data.attrib.get('audioProfile') 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.container = data.attrib.get('container')
self.duration = cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.height = cast(int, data.attrib.get('height')) self.height = utils.cast(int, data.attrib.get('height'))
self.id = cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets')) self.has64bitOffsets = utils.cast(bool, data.attrib.get('has64bitOffsets'))
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming')) self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming'))
self.parts = self.findItems(data, MediaPart) 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.target = data.attrib.get('target')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.videoCodec = data.attrib.get('videoCodec') self.videoCodec = data.attrib.get('videoCodec')
self.videoFrameRate = data.attrib.get('videoFrameRate') self.videoFrameRate = data.attrib.get('videoFrameRate')
self.videoProfile = data.attrib.get('videoProfile') self.videoProfile = data.attrib.get('videoProfile')
self.videoResolution = data.attrib.get('videoResolution') 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'): if self._isChildOf(etag='Photo'):
self.aperture = data.attrib.get('aperture') self.aperture = data.attrib.get('aperture')
self.exposure = data.attrib.get('exposure') 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.lens = data.attrib.get('lens')
self.make = data.attrib.get('make') self.make = data.attrib.get('make')
self.model = data.attrib.get('model') self.model = data.attrib.get('model')
@ -112,7 +111,7 @@ class MediaPart(PlexObject):
has64bitOffsets (bool): True if the file has 64 bit offsets. has64bitOffsets (bool): True if the file has 64 bit offsets.
hasThumbnail (bool): True if the file (track) has an embedded thumbnail. hasThumbnail (bool): True if the file (track) has an embedded thumbnail.
id (int): The unique ID for this media part on the server. 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). key (str): API URL (ex: /library/parts/46618/1389985872/file.mkv).
optimizedForStreaming (bool): True if the file is optimized for streaming. optimizedForStreaming (bool): True if the file is optimized for streaming.
packetLength (int): The packet length of the file. packetLength (int): The packet length of the file.
@ -128,25 +127,25 @@ class MediaPart(PlexObject):
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
self._data = data 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.audioProfile = data.attrib.get('audioProfile')
self.container = data.attrib.get('container') self.container = data.attrib.get('container')
self.decision = data.attrib.get('decision') self.decision = data.attrib.get('decision')
self.deepAnalysisVersion = cast(int, data.attrib.get('deepAnalysisVersion')) self.deepAnalysisVersion = utils.cast(int, data.attrib.get('deepAnalysisVersion'))
self.duration = cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.exists = cast(bool, data.attrib.get('exists')) self.exists = utils.cast(bool, data.attrib.get('exists'))
self.file = data.attrib.get('file') self.file = data.attrib.get('file')
self.has64bitOffsets = cast(bool, data.attrib.get('has64bitOffsets')) self.has64bitOffsets = utils.cast(bool, data.attrib.get('has64bitOffsets'))
self.hasThumbnail = cast(bool, data.attrib.get('hasThumbnail')) self.hasThumbnail = utils.cast(bool, data.attrib.get('hasThumbnail'))
self.id = cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
self.indexes = data.attrib.get('indexes') self.indexes = data.attrib.get('indexes')
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.optimizedForStreaming = cast(bool, data.attrib.get('optimizedForStreaming')) self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming'))
self.packetLength = cast(int, data.attrib.get('packetLength')) self.packetLength = utils.cast(int, data.attrib.get('packetLength'))
self.requiredBandwidths = data.attrib.get('requiredBandwidths') 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.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.syncState = data.attrib.get('syncState')
self.videoProfile = data.attrib.get('videoProfile') self.videoProfile = data.attrib.get('videoProfile')
@ -157,6 +156,11 @@ class MediaPart(PlexObject):
streams.extend(items) streams.extend(items)
return streams return streams
@property
def hasPreviewThumbnails(self):
""" Returns True if the media part has generated preview (BIF) thumbnails. """
return self.indexes == 'sd'
def videoStreams(self): def videoStreams(self):
""" Returns a list of :class:`~plexapi.media.VideoStream` objects in this MediaPart. """ """ Returns a list of :class:`~plexapi.media.VideoStream` objects in this MediaPart. """
return [stream for stream in self.streams if isinstance(stream, VideoStream)] return [stream for stream in self.streams if isinstance(stream, VideoStream)]
@ -228,21 +232,21 @@ class MediaPartStream(PlexObject):
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
self._data = data 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.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.displayTitle = data.attrib.get('displayTitle')
self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle') self.extendedDisplayTitle = data.attrib.get('extendedDisplayTitle')
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.id = cast(int, data.attrib.get('id')) self.id = utils.cast(int, data.attrib.get('id'))
self.index = cast(int, data.attrib.get('index', '-1')) self.index = utils.cast(int, data.attrib.get('index', '-1'))
self.language = data.attrib.get('language') self.language = data.attrib.get('language')
self.languageCode = data.attrib.get('languageCode') self.languageCode = data.attrib.get('languageCode')
self.requiredBandwidths = data.attrib.get('requiredBandwidths') self.requiredBandwidths = data.attrib.get('requiredBandwidths')
self.selected = cast(bool, data.attrib.get('selected', '0')) self.selected = utils.cast(bool, data.attrib.get('selected', '0'))
self.streamType = cast(int, data.attrib.get('streamType')) self.streamType = utils.cast(int, data.attrib.get('streamType'))
self.title = data.attrib.get('title') 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 @utils.registerPlexObject
@ -293,38 +297,38 @@ class VideoStream(MediaPartStream):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
super(VideoStream, self)._loadData(data) super(VideoStream, self)._loadData(data)
self.anamorphic = data.attrib.get('anamorphic') self.anamorphic = data.attrib.get('anamorphic')
self.bitDepth = cast(int, data.attrib.get('bitDepth')) self.bitDepth = utils.cast(int, data.attrib.get('bitDepth'))
self.cabac = cast(int, data.attrib.get('cabac')) self.cabac = utils.cast(int, data.attrib.get('cabac'))
self.chromaLocation = data.attrib.get('chromaLocation') self.chromaLocation = data.attrib.get('chromaLocation')
self.chromaSubsampling = data.attrib.get('chromaSubsampling') self.chromaSubsampling = data.attrib.get('chromaSubsampling')
self.codecID = data.attrib.get('codecID') self.codecID = data.attrib.get('codecID')
self.codedHeight = cast(int, data.attrib.get('codedHeight')) self.codedHeight = utils.cast(int, data.attrib.get('codedHeight'))
self.codedWidth = cast(int, data.attrib.get('codedWidth')) self.codedWidth = utils.cast(int, data.attrib.get('codedWidth'))
self.colorPrimaries = data.attrib.get('colorPrimaries') self.colorPrimaries = data.attrib.get('colorPrimaries')
self.colorRange = data.attrib.get('colorRange') self.colorRange = data.attrib.get('colorRange')
self.colorSpace = data.attrib.get('colorSpace') self.colorSpace = data.attrib.get('colorSpace')
self.colorTrc = data.attrib.get('colorTrc') self.colorTrc = data.attrib.get('colorTrc')
self.DOVIBLCompatID = cast(int, data.attrib.get('DOVIBLCompatID')) self.DOVIBLCompatID = utils.cast(int, data.attrib.get('DOVIBLCompatID'))
self.DOVIBLPresent = cast(bool, data.attrib.get('DOVIBLPresent')) self.DOVIBLPresent = utils.cast(bool, data.attrib.get('DOVIBLPresent'))
self.DOVIELPresent = cast(bool, data.attrib.get('DOVIELPresent')) self.DOVIELPresent = utils.cast(bool, data.attrib.get('DOVIELPresent'))
self.DOVILevel = cast(int, data.attrib.get('DOVILevel')) self.DOVILevel = utils.cast(int, data.attrib.get('DOVILevel'))
self.DOVIPresent = cast(bool, data.attrib.get('DOVIPresent')) self.DOVIPresent = utils.cast(bool, data.attrib.get('DOVIPresent'))
self.DOVIProfile = cast(int, data.attrib.get('DOVIProfile')) self.DOVIProfile = utils.cast(int, data.attrib.get('DOVIProfile'))
self.DOVIRPUPresent = cast(bool, data.attrib.get('DOVIRPUPresent')) self.DOVIRPUPresent = utils.cast(bool, data.attrib.get('DOVIRPUPresent'))
self.DOVIVersion = cast(float, data.attrib.get('DOVIVersion')) self.DOVIVersion = utils.cast(float, data.attrib.get('DOVIVersion'))
self.duration = cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.frameRate = cast(float, data.attrib.get('frameRate')) self.frameRate = utils.cast(float, data.attrib.get('frameRate'))
self.frameRateMode = data.attrib.get('frameRateMode') self.frameRateMode = data.attrib.get('frameRateMode')
self.hasScallingMatrix = cast(bool, data.attrib.get('hasScallingMatrix')) self.hasScallingMatrix = utils.cast(bool, data.attrib.get('hasScallingMatrix'))
self.height = cast(int, data.attrib.get('height')) self.height = utils.cast(int, data.attrib.get('height'))
self.level = cast(int, data.attrib.get('level')) self.level = utils.cast(int, data.attrib.get('level'))
self.profile = data.attrib.get('profile') self.profile = data.attrib.get('profile')
self.pixelAspectRatio = data.attrib.get('pixelAspectRatio') self.pixelAspectRatio = data.attrib.get('pixelAspectRatio')
self.pixelFormat = data.attrib.get('pixelFormat') 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.scanType = data.attrib.get('scanType')
self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier')) self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier'))
self.width = cast(int, data.attrib.get('width')) self.width = utils.cast(int, data.attrib.get('width'))
@utils.registerPlexObject @utils.registerPlexObject
@ -362,23 +366,23 @@ class AudioStream(MediaPartStream):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
super(AudioStream, self)._loadData(data) super(AudioStream, self)._loadData(data)
self.audioChannelLayout = data.attrib.get('audioChannelLayout') 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.bitrateMode = data.attrib.get('bitrateMode')
self.channels = cast(int, data.attrib.get('channels')) self.channels = utils.cast(int, data.attrib.get('channels'))
self.duration = cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.profile = data.attrib.get('profile') self.profile = data.attrib.get('profile')
self.samplingRate = cast(int, data.attrib.get('samplingRate')) self.samplingRate = utils.cast(int, data.attrib.get('samplingRate'))
self.streamIdentifier = cast(int, data.attrib.get('streamIdentifier')) self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier'))
if self._isChildOf(etag='Track'): if self._isChildOf(etag='Track'):
self.albumGain = cast(float, data.attrib.get('albumGain')) self.albumGain = utils.cast(float, data.attrib.get('albumGain'))
self.albumPeak = cast(float, data.attrib.get('albumPeak')) self.albumPeak = utils.cast(float, data.attrib.get('albumPeak'))
self.albumRange = cast(float, data.attrib.get('albumRange')) self.albumRange = utils.cast(float, data.attrib.get('albumRange'))
self.endRamp = data.attrib.get('endRamp') self.endRamp = data.attrib.get('endRamp')
self.gain = cast(float, data.attrib.get('gain')) self.gain = utils.cast(float, data.attrib.get('gain'))
self.loudness = cast(float, data.attrib.get('loudness')) self.loudness = utils.cast(float, data.attrib.get('loudness'))
self.lra = cast(float, data.attrib.get('lra')) self.lra = utils.cast(float, data.attrib.get('lra'))
self.peak = cast(float, data.attrib.get('peak')) self.peak = utils.cast(float, data.attrib.get('peak'))
self.startRamp = data.attrib.get('startRamp') self.startRamp = data.attrib.get('startRamp')
@ -402,7 +406,7 @@ class SubtitleStream(MediaPartStream):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
super(SubtitleStream, self)._loadData(data) super(SubtitleStream, self)._loadData(data)
self.container = data.attrib.get('container') 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.format = data.attrib.get('format')
self.headerCompression = data.attrib.get('headerCompression') self.headerCompression = data.attrib.get('headerCompression')
self.transient = data.attrib.get('transient') self.transient = data.attrib.get('transient')
@ -426,9 +430,9 @@ class LyricStream(MediaPartStream):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
super(LyricStream, self)._loadData(data) super(LyricStream, self)._loadData(data)
self.format = data.attrib.get('format') 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.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 @utils.registerPlexObject
@ -491,36 +495,36 @@ class TranscodeSession(PlexObject):
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
self._data = data 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.audioCodec = data.attrib.get('audioCodec')
self.audioDecision = data.attrib.get('audioDecision') 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.container = data.attrib.get('container')
self.context = data.attrib.get('context') self.context = data.attrib.get('context')
self.duration = cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.height = cast(int, data.attrib.get('height')) self.height = utils.cast(int, data.attrib.get('height'))
self.key = data.attrib.get('key') self.key = data.attrib.get('key')
self.maxOffsetAvailable = cast(float, data.attrib.get('maxOffsetAvailable')) self.maxOffsetAvailable = utils.cast(float, data.attrib.get('maxOffsetAvailable'))
self.minOffsetAvailable = cast(float, data.attrib.get('minOffsetAvailable')) self.minOffsetAvailable = utils.cast(float, data.attrib.get('minOffsetAvailable'))
self.progress = cast(float, data.attrib.get('progress')) self.progress = utils.cast(float, data.attrib.get('progress'))
self.protocol = data.attrib.get('protocol') self.protocol = data.attrib.get('protocol')
self.remaining = cast(int, data.attrib.get('remaining')) self.remaining = utils.cast(int, data.attrib.get('remaining'))
self.size = cast(int, data.attrib.get('size')) self.size = utils.cast(int, data.attrib.get('size'))
self.sourceAudioCodec = data.attrib.get('sourceAudioCodec') self.sourceAudioCodec = data.attrib.get('sourceAudioCodec')
self.sourceVideoCodec = data.attrib.get('sourceVideoCodec') 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.subtitleDecision = data.attrib.get('subtitleDecision')
self.throttled = cast(bool, data.attrib.get('throttled', '0')) self.throttled = utils.cast(bool, data.attrib.get('throttled', '0'))
self.timestamp = cast(float, data.attrib.get('timeStamp')) self.timestamp = utils.cast(float, data.attrib.get('timeStamp'))
self.transcodeHwDecoding = data.attrib.get('transcodeHwDecoding') self.transcodeHwDecoding = data.attrib.get('transcodeHwDecoding')
self.transcodeHwDecodingTitle = data.attrib.get('transcodeHwDecodingTitle') self.transcodeHwDecodingTitle = data.attrib.get('transcodeHwDecodingTitle')
self.transcodeHwEncoding = data.attrib.get('transcodeHwEncoding') self.transcodeHwEncoding = data.attrib.get('transcodeHwEncoding')
self.transcodeHwEncodingTitle = data.attrib.get('transcodeHwEncodingTitle') self.transcodeHwEncodingTitle = data.attrib.get('transcodeHwEncodingTitle')
self.transcodeHwFullPipeline = cast(bool, data.attrib.get('transcodeHwFullPipeline', '0')) self.transcodeHwFullPipeline = utils.cast(bool, data.attrib.get('transcodeHwFullPipeline', '0'))
self.transcodeHwRequested = cast(bool, data.attrib.get('transcodeHwRequested', '0')) self.transcodeHwRequested = utils.cast(bool, data.attrib.get('transcodeHwRequested', '0'))
self.videoCodec = data.attrib.get('videoCodec') self.videoCodec = data.attrib.get('videoCodec')
self.videoDecision = data.attrib.get('videoDecision') 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 @utils.registerPlexObject
@ -558,6 +562,13 @@ class Optimized(PlexObject):
self.target = data.attrib.get('target') self.target = data.attrib.get('target')
self.targetTagID = data.attrib.get('targetTagID') 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): def remove(self):
""" Remove an Optimized item""" """ Remove an Optimized item"""
key = '%s/%s' % (self._initpath, self.id) 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. the construct used for things such as Country, Director, Genre, etc.
Attributes: 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). id (id): Tag ID (This seems meaningless except to use it as a unique id).
role (str): Unknown key (str): API URL (/library/section/<librarySectionID>/all?<filter>).
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 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). person for Directors and Roles (ex: Animation, Stephen Graham, etc).
<Hub_Search_Attributes>: Attributes only applicable in search results from thumb (str): URL to thumbnail image for :class:`~plexapi.media.Role` only.
PlexServer :func:`~plexapi.server.PlexServer.search`. They provide details of which
library section the tag was found as well as the url to dig deeper into the results.
* key (str): API URL to dig deeper into this tag (ex: /library/sections/1/all?actor=9081).
* librarySectionID (int): Section ID this tag was generated from.
* librarySectionTitle (str): Library section title this tag was found.
* librarySectionType (str): Media type of the library section this tag was found.
* tagType (int): Tag type ID.
* thumb (str): URL to thumbnail image.
""" """
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
self._data = data 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.role = data.attrib.get('role')
self.tag = data.attrib.get('tag') 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') self.thumb = data.attrib.get('thumb')
def items(self, *args, **kwargs): parent = self._parent()
""" Return the list of items within this tag. This function is only applicable self._librarySectionID = utils.cast(int, parent._data.attrib.get('librarySectionID'))
in search results from PlexServer :func:`~plexapi.server.PlexServer.search`. 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: 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) 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 @utils.registerPlexObject
class Collection(MediaTag): class Collection(MediaTag):
""" Represents a single Collection media tag. """ Represents a single Collection media tag.
@ -705,36 +700,11 @@ class Collection(MediaTag):
TAG = 'Collection' TAG = 'Collection'
FILTER = 'collection' FILTER = 'collection'
def collection(self):
@utils.registerPlexObject """ Return the :class:`~plexapi.collection.Collection` object for this collection tag.
class Label(MediaTag): """
""" Represents a single Label media tag. key = '%s/collections' % self._librarySectionKey
return self.fetchItem(key, etag='Directory', index=self.id)
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
@utils.registerPlexObject @utils.registerPlexObject
@ -774,13 +744,15 @@ class Genre(MediaTag):
@utils.registerPlexObject @utils.registerPlexObject
class Guid(GuidTag): class Label(MediaTag):
""" Represents a single Guid media tag. """ Represents a single Label media tag.
Attributes: Attributes:
TAG (str): 'Guid' TAG (str): 'Label'
FILTER (str): 'label'
""" """
TAG = "Guid" TAG = 'Label'
FILTER = 'label'
@utils.registerPlexObject @utils.registerPlexObject
@ -795,60 +767,6 @@ class Mood(MediaTag):
FILTER = 'mood' 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/<ratingkey>).
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 @utils.registerPlexObject
class Producer(MediaTag): class Producer(MediaTag):
""" Represents a single Producer media tag. """ Represents a single Producer media tag.
@ -885,6 +803,30 @@ class Similar(MediaTag):
FILTER = 'similar' 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 @utils.registerPlexObject
class Writer(MediaTag): class Writer(MediaTag):
""" Represents a single Writer media tag. """ Represents a single Writer media tag.
@ -897,6 +839,98 @@ class Writer(MediaTag):
FILTER = 'writer' 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/<ratingkey>).
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 @utils.registerPlexObject
class Chapter(PlexObject): class Chapter(PlexObject):
""" Represents a single Writer media tag. """ Represents a single Writer media tag.
@ -908,13 +942,13 @@ class Chapter(PlexObject):
def _loadData(self, data): def _loadData(self, data):
self._data = 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.filter = data.attrib.get('filter') # I couldn't filter on it anyways
self.tag = data.attrib.get('tag') self.tag = data.attrib.get('tag')
self.title = self.tag self.title = self.tag
self.index = cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.start = cast(int, data.attrib.get('startTimeOffset')) self.start = utils.cast(int, data.attrib.get('startTimeOffset'))
self.end = cast(int, data.attrib.get('endTimeOffset')) self.end = utils.cast(int, data.attrib.get('endTimeOffset'))
@utils.registerPlexObject @utils.registerPlexObject
@ -935,8 +969,8 @@ class Marker(PlexObject):
def _loadData(self, data): def _loadData(self, data):
self._data = data self._data = data
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.start = cast(int, data.attrib.get('startTimeOffset')) self.start = utils.cast(int, data.attrib.get('startTimeOffset'))
self.end = cast(int, data.attrib.get('endTimeOffset')) self.end = utils.cast(int, data.attrib.get('endTimeOffset'))
@utils.registerPlexObject @utils.registerPlexObject
@ -951,7 +985,7 @@ class Field(PlexObject):
def _loadData(self, data): def _loadData(self, data):
self._data = data self._data = data
self.name = data.attrib.get('name') 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 @utils.registerPlexObject
@ -973,7 +1007,7 @@ class SearchResult(PlexObject):
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.lifespanEnded = data.attrib.get('lifespanEnded') self.lifespanEnded = data.attrib.get('lifespanEnded')
self.name = data.attrib.get('name') 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') 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]) return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p])
def _loadData(self, data): 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.name = data.attrib.get('name')
self.languageCode = [] self.languageCode = []
for code in data: for code in data:

View file

@ -2,7 +2,7 @@
from urllib.parse import quote_plus, urlencode from urllib.parse import quote_plus, urlencode
from plexapi import media, settings, utils from plexapi import media, settings, utils
from plexapi.exceptions import NotFound from plexapi.exceptions import BadRequest, NotFound
class AdvancedSettingsMixin(object): class AdvancedSettingsMixin(object):
@ -10,15 +10,8 @@ class AdvancedSettingsMixin(object):
def preferences(self): def preferences(self):
""" Returns a list of :class:`~plexapi.settings.Preferences` objects. """ """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """
items = []
data = self._server.query(self._details_key) data = self._server.query(self._details_key)
for item in data.iter('Preferences'): return self.findItems(data, settings.Preferences, rtag='Preferences')
for elem in item:
setting = settings.Preferences(data=elem, server=self._server)
setting._initpath = self.key
items.append(setting)
return items
def preference(self, pref): def preference(self, pref):
""" Returns a :class:`~plexapi.settings.Preferences` object for the specified 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. """ """ Edit a Plex object's advanced settings. """
data = {} data = {}
key = '%s/prefs?' % self.key 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(): for settingID, value in kwargs.items():
enumValues = preferences.get(settingID) try:
if value in enumValues: 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 data[settingID] = value
else: else:
raise NotFound('%s not found in %s' % (value, enumValues)) raise NotFound('%s not found in %s' % (value, list(enumValues)))
url = key + urlencode(data) url = key + urlencode(data)
self._server.query(url, method=self._server._session.put) self._server.query(url, method=self._server._session.put)
@ -187,6 +185,26 @@ class PosterMixin(PosterUrlMixin):
poster.select() 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): class SplitMergeMixin(object):
""" Mixin for Plex objects that can be split and merged. """ """ Mixin for Plex objects that can be split and merged. """

View file

@ -14,7 +14,6 @@ from plexapi.library import LibrarySection
from plexapi.server import PlexServer from plexapi.server import PlexServer
from plexapi.sonos import PlexSonosClient from plexapi.sonos import PlexSonosClient
from plexapi.sync import SyncItem, SyncList from plexapi.sync import SyncItem, SyncList
from plexapi.utils import joinArgs
from requests.status_codes import _codes as codes from requests.status_codes import _codes as codes
@ -76,6 +75,7 @@ class MyPlexAccount(PlexObject):
REQUESTS = 'https://plex.tv/api/invites/requests' # get REQUESTS = 'https://plex.tv/api/invites/requests' # get
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data 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 LINK = 'https://plex.tv/api/v2/pins/link' # put
# Hub sections # Hub sections
VOD = 'https://vod.provider.plex.tv/' # get VOD = 'https://vod.provider.plex.tv/' # get
@ -128,26 +128,16 @@ class MyPlexAccount(PlexObject):
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.username = data.attrib.get('username') self.username = data.attrib.get('username')
self.uuid = data.attrib.get('uuid') self.uuid = data.attrib.get('uuid')
subscription = data.find('subscription')
subscription = data.find('subscription')
self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active')) self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active'))
self.subscriptionStatus = subscription.attrib.get('status') self.subscriptionStatus = subscription.attrib.get('status')
self.subscriptionPlan = subscription.attrib.get('plan') self.subscriptionPlan = subscription.attrib.get('plan')
self.subscriptionFeatures = self.listAttrs(subscription, 'id', etag='feature')
self.subscriptionFeatures = [] self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role')
for feature in subscription.iter('feature'):
self.subscriptionFeatures.append(feature.attrib.get('id'))
roles = data.find('roles') self.entitlements = self.listAttrs(data, 'id', rtag='entitlements', etag='entitlement')
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'))
# TODO: Fetch missing MyPlexAccount attributes # TODO: Fetch missing MyPlexAccount attributes
self.profile_settings = None self.profile_settings = None
@ -460,7 +450,7 @@ class MyPlexAccount(PlexObject):
if isinstance(allowChannels, dict): if isinstance(allowChannels, dict):
params['filterMusic'] = self._filterDictToStr(filterMusic or {}) params['filterMusic'] = self._filterDictToStr(filterMusic or {})
if params: if params:
url += joinArgs(params) url += utils.joinArgs(params)
response_filters = self.query(url, self._session.put) response_filters = self.query(url, self._session.put)
return response_servers, response_filters return response_servers, response_filters
@ -470,6 +460,7 @@ class MyPlexAccount(PlexObject):
Parameters: Parameters:
username (str): Username, email or id of the user to return. username (str): Username, email or id of the user to return.
""" """
username = str(username)
for user in self.users(): for user in self.users():
# Home users don't have email, username etc. # Home users don't have email, username etc.
if username.lower() == user.title.lower(): if username.lower() == user.title.lower():
@ -698,6 +689,13 @@ class MyPlexAccount(PlexObject):
elem = ElementTree.fromstring(req.text) elem = ElementTree.fromstring(req.text)
return self.findItems(elem) 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): def link(self, pin):
""" Link a device to the account using a pin code. """ 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) url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id)
data = self._server.query(url) data = self._server.query(url)
sections = [] return self.findItems(data, Section, rtag='SharedServer')
for section in data.iter('Section'):
if ElementTree.iselement(section):
sections.append(Section(self, section, url))
return sections
def history(self, maxresults=9999999, mindate=None): def history(self, maxresults=9999999, mindate=None):
""" Get all Play History for a user in this shared server. """ 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.screenDensity = data.attrib.get('screenDensity')
self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) 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): def connect(self, timeout=None):
""" Returns a new :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer` """ 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) log.debug('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token)
return results[0] return results[0]
raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name)) 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')

View file

@ -4,11 +4,11 @@ from urllib.parse import quote_plus
from plexapi import media, utils, video from plexapi import media, utils, video
from plexapi.base import Playable, PlexPartialObject from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest 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 @utils.registerPlexObject
class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin): class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin):
""" Represents a single Photoalbum (collection of photos). """ Represents a single Photoalbum (collection of photos).
Attributes: Attributes:
@ -21,6 +21,7 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin):
guid (str): Plex GUID for the photo album (local://229674). guid (str): Plex GUID for the photo album (local://229674).
index (sting): Plex index number for the photo album. index (sting): Plex index number for the photo album.
key (str): API URL (/library/metadata/<ratingkey>). key (str): API URL (/library/metadata/<ratingkey>).
lastRatedAt (datetime): Datetime the photo album was last rated.
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. 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). titleSort (str): Title to use when sorting (defaults to title).
type (str): 'photo' type (str): 'photo'
updatedAt (datatime): Datetime the photo album was updated. 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' TAG = 'Directory'
TYPE = 'photo' TYPE = 'photo'
@ -46,6 +47,7 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin):
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.librarySectionTitle = data.attrib.get('librarySectionTitle')
@ -57,7 +59,7 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin):
self.titleSort = data.attrib.get('titleSort', self.title) self.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) 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): def album(self, title):
""" Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title. """ Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title.
@ -137,7 +139,7 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin):
@utils.registerPlexObject @utils.registerPlexObject
class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, TagMixin): class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin, TagMixin):
""" Represents a single Photo. """ Represents a single Photo.
Attributes: 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). guid (str): Plex GUID for the photo (com.plexapp.agents.none://231714?lang=xn).
index (sting): Plex index number for the photo. index (sting): Plex index number for the photo.
key (str): API URL (/library/metadata/<ratingkey>). key (str): API URL (/library/metadata/<ratingkey>).
lastRatedAt (datetime): Datetime the photo was last rated.
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title. 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). titleSort (str): Title to use when sorting (defaults to title).
type (str): 'photo' type (str): 'photo'
updatedAt (datatime): Datetime the photo was updated. 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. year (int): Year the photo was taken.
""" """
TAG = 'Photo' TAG = 'Photo'
@ -186,6 +190,7 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, TagMixin):
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '') self.key = data.attrib.get('key', '')
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle') 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.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) 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')) self.year = utils.cast(int, data.attrib.get('year'))
def photoalbum(self): 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 """ This does not exist in plex xml response but is added to have a common
interface to get the locations of the photo. interface to get the locations of the photo.
Retruns: Returns:
List<str> of file paths where the photo is found on disk. List<str> 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] return [part.file for item in self.media for part in item.parts if part]

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from urllib.parse import quote_plus import re
from urllib.parse import quote_plus, unquote
from plexapi import utils from plexapi import utils
from plexapi.base import Playable, PlexPartialObject from plexapi.base import Playable, PlexPartialObject
@ -7,7 +8,7 @@ from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection from plexapi.library import LibrarySection
from plexapi.mixins import ArtMixin, PosterMixin from plexapi.mixins import ArtMixin, PosterMixin
from plexapi.playqueue import PlayQueue from plexapi.playqueue import PlayQueue
from plexapi.utils import cast, toDatetime from plexapi.utils import deprecated
@utils.registerPlexObject @utils.registerPlexObject
@ -20,9 +21,11 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
addedAt (datetime): Datetime the playlist was added to the server. addedAt (datetime): Datetime the playlist was added to the server.
allowSync (bool): True if you allow syncing playlists. allowSync (bool): True if you allow syncing playlists.
composite (str): URL to composite image (/playlist/<ratingKey>/composite/<compositeid>) composite (str): URL to composite image (/playlist/<ratingKey>/composite/<compositeid>)
content (str): The filter URI string for smart playlists.
duration (int): Duration of the playlist in milliseconds. duration (int): Duration of the playlist in milliseconds.
durationInSeconds (int): Duration of the playlist in seconds. durationInSeconds (int): Duration of the playlist in seconds.
guid (str): Plex GUID for the playlist (com.plexapp.agents.none://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX). 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/<ratingkey>). key (str): API URL (/playlist/<ratingkey>).
leafCount (int): Number of items in the playlist view. leafCount (int): Number of items in the playlist view.
playlistType (str): 'audio', 'video', or 'photo' playlistType (str): 'audio', 'video', or 'photo'
@ -39,22 +42,25 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
Playable._loadData(self, data) Playable._loadData(self, data)
self.addedAt = toDatetime(data.attrib.get('addedAt')) self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.allowSync = cast(bool, data.attrib.get('allowSync')) self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
self.composite = data.attrib.get('composite') # url to thumbnail self.composite = data.attrib.get('composite') # url to thumbnail
self.duration = cast(int, data.attrib.get('duration')) self.content = data.attrib.get('content')
self.durationInSeconds = cast(int, data.attrib.get('durationInSeconds')) 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.guid = data.attrib.get('guid')
self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50 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.playlistType = data.attrib.get('playlistType')
self.ratingKey = cast(int, data.attrib.get('ratingKey')) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.smart = cast(bool, data.attrib.get('smart')) self.smart = utils.cast(bool, data.attrib.get('smart'))
self.summary = data.attrib.get('summary') self.summary = data.attrib.get('summary')
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.type = data.attrib.get('type') 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._items = None # cache for self.items
self._section = None # cache for self.section
def __len__(self): # pragma: no cover def __len__(self): # pragma: no cover
return len(self.items()) return len(self.items())
@ -63,6 +69,12 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
for item in self.items(): for item in self.items():
yield item 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 @property
def thumb(self): def thumb(self):
""" Alias to self.composite. """ """ Alias to self.composite. """
@ -70,6 +82,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
@property @property
def metadataType(self): def metadataType(self):
""" Returns the type of metadata in the playlist (movie, track, or photo). """
if self.isVideo: if self.isVideo:
return 'movie' return 'movie'
elif self.isAudio: elif self.isAudio:
@ -81,27 +94,54 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
@property @property
def isVideo(self): def isVideo(self):
""" Returns True if this is a video playlist. """
return self.playlistType == 'video' return self.playlistType == 'video'
@property @property
def isAudio(self): def isAudio(self):
""" Returns True if this is an audio playlist. """
return self.playlistType == 'audio' return self.playlistType == 'audio'
@property @property
def isPhoto(self): def isPhoto(self):
""" Returns True if this is a photo playlist. """
return self.playlistType == 'photo' return self.playlistType == 'photo'
def __contains__(self, other): # pragma: no cover def section(self):
return any(i.key == other.key for i in self.items()) """ Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to.
def __getitem__(self, key): # pragma: no cover Raises:
return self.items()[key] :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): def item(self, title):
""" Returns the item in the playlist that matches the specified title. """ Returns the item in the playlist that matches the specified title.
Parameters: Parameters:
title (str): Title of the item to return. 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(): for item in self.items():
if item.title.lower() == title.lower(): if item.title.lower() == title.lower():
@ -111,7 +151,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
def items(self): def items(self):
""" Returns a list of all items in the playlist. """ """ Returns a list of all items in the playlist. """
if self._items is None: if self._items is None:
key = '/playlists/%s/items' % self.ratingKey key = '%s/items' % self.key
items = self.fetchItems(key) items = self.fetchItems(key)
self._items = items self._items = items
return self._items return self._items
@ -120,74 +160,170 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
""" Alias to :func:`~plexapi.playlist.Playlist.item`. """ """ Alias to :func:`~plexapi.playlist.Playlist.item`. """
return self.item(title) 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): def addItems(self, items):
""" Add items to a playlist. """ """ Add items to the playlist.
if not isinstance(items, (list, tuple)):
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] items = [items]
ratingKeys = [] ratingKeys = []
for item in items: for item in items:
if item.listType != self.playlistType: # pragma: no cover if item.listType != self.playlistType: # pragma: no cover
raise BadRequest('Can not mix media types when building a playlist: %s and %s' % raise BadRequest('Can not mix media types when building a playlist: %s and %s' %
(self.playlistType, item.listType)) (self.playlistType, item.listType))
ratingKeys.append(str(item.ratingKey)) 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): def removeItem(self, item):
""" Remove a file from a playlist. """ self.removeItems(item)
key = '%s/items/%s' % (self.key, item.playlistItemID)
result = self._server.query(key, method=self._server._session.delete) def removeItems(self, items):
self.reload() """ Remove items from the playlist.
return result
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): def moveItem(self, item, after=None):
""" Move a to a new position in playlist. """ """ Move an item to a new position in playlist.
key = '%s/items/%s/move' % (self.key, item.playlistItemID)
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: if after:
key += '?after=%s' % after.playlistItemID afterPlaylistItemID = self._getPlaylistItemID(after)
result = self._server.query(key, method=self._server._session.put) key += '?after=%s' % afterPlaylistItemID
self.reload()
return result 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): def edit(self, title=None, summary=None):
""" Edit playlist. """ """ Edit the playlist.
key = '/library/metadata/%s%s' % (self.ratingKey, utils.joinArgs({'title': title, 'summary': summary}))
result = self._server.query(key, method=self._server._session.put) Parameters:
self.reload() title (str, optional): The title of the playlist.
return result 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): def delete(self):
""" Delete playlist. """ """ Delete the playlist. """
return self._server.query(self.key, method=self._server._session.delete) self._server.query(self.key, method=self._server._session.delete)
def playQueue(self, *args, **kwargs): 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) return PlayQueue.create(self._server, self, *args, **kwargs)
@classmethod @classmethod
def _create(cls, server, title, items): def _create(cls, server, title, items):
""" Create a playlist. """ """ Create a regular playlist. """
if not items: 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)): if items and not isinstance(items, (list, tuple)):
items = [items] items = [items]
listType = items[0].listType
ratingKeys = [] ratingKeys = []
for item in items: for item in items:
if item.listType != items[0].listType: # pragma: no cover if item.listType != listType: # pragma: no cover
raise BadRequest('Can not mix media types when building a playlist') raise BadRequest('Can not mix media types when building a playlist.')
ratingKeys.append(str(item.ratingKey)) ratingKeys.append(str(item.ratingKey))
ratingKeys = ','.join(ratingKeys) ratingKeys = ','.join(ratingKeys)
uuid = items[0].section().uuid uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys)
key = '/playlists%s' % utils.joinArgs({ key = '/playlists%s' % utils.joinArgs({
'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys), 'uri': uri,
'type': items[0].listType, 'type': listType,
'title': title, 'title': title,
'smart': 0 'smart': 0
}) })
@ -195,54 +331,15 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
return cls(server, data, initpath=key) return cls(server, data, initpath=key)
@classmethod @classmethod
def create(cls, server, title, items=None, section=None, limit=None, smart=False, **kwargs): def _createSmart(cls, server, title, section, limit=None, sort=None, filters=None, **kwargs):
"""Create a playlist. """ Create a smart 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. """
if not isinstance(section, LibrarySection): if not isinstance(section, LibrarySection):
section = server.library.section(section) section = server.library.section(section)
sectionType = utils.searchType(section.type) searchKey = section._buildSearchKey(
sectionId = section.key sort=sort, libtype=section.METADATA_TYPE, limit=limit, filters=filters, **kwargs)
uuid = section.uuid uri = '%s%s' % (server._uriRoot(), searchKey)
uri = 'library://%s/directory//library/sections/%s/all?type=%s' % (uuid,
sectionId,
sectionType)
if limit:
uri = uri + '&limit=%s' % str(limit)
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({ key = '/playlists%s' % utils.joinArgs({
'uri': uri, 'uri': uri,
'type': section.CONTENT_TYPE, 'type': section.CONTENT_TYPE,
@ -252,20 +349,52 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
data = server.query(key, method=server._session.post)[0] data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key) 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): def copyToUser(self, user):
""" Copy playlist to another user account. """ """ Copy playlist to another user account.
from plexapi.server import PlexServer
myplex = self._server.myPlexAccount() Parameters:
user = myplex.user(user) user (str): Username, email or user id of the user to copy the playlist to.
# Get the token for your machine. """
token = user.get_token(self._server.machineIdentifier) userServer = self._server.switchUser(user)
# Login to your server using your friends credentials. return self.create(server=userServer, title=self.title, items=self.items())
user_server = PlexServer(self._server._baseurl, token)
return self.create(user_server, self.title, self.items())
def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None, def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None,
unwatched=False, title=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. See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
Parameters: Parameters:
@ -288,9 +417,8 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
:exc:`~plexapi.exceptions.Unsupported`: When playlist content is unsupported. :exc:`~plexapi.exceptions.Unsupported`: When playlist content is unsupported.
Returns: 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: if not self.allowSync:
raise BadRequest('The playlist is not allowed to sync') raise BadRequest('The playlist is not allowed to sync')

View file

@ -9,13 +9,14 @@ from plexapi import utils
from plexapi.alert import AlertListener from plexapi.alert import AlertListener
from plexapi.base import PlexObject from plexapi.base import PlexObject
from plexapi.client import PlexClient from plexapi.client import PlexClient
from plexapi.collection import Collection
from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.library import Hub, Library, Path, File from plexapi.library import Hub, Library, Path, File
from plexapi.media import Conversion, Optimized from plexapi.media import Conversion, Optimized
from plexapi.playlist import Playlist from plexapi.playlist import Playlist
from plexapi.playqueue import PlayQueue from plexapi.playqueue import PlayQueue
from plexapi.settings import Settings from plexapi.settings import Settings
from plexapi.utils import cast, deprecated from plexapi.utils import deprecated
from requests.status_codes import _codes as codes from requests.status_codes import _codes as codes
# Need these imports to populate utils.PLEXOBJECTS # 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'). 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. token (str): Required Plex authentication token to access the server.
session (requests.Session, optional): Use your own session object if you want to session (requests.Session, optional): Use your own session object if you want to
cache the http responses from PMS cache the http responses from the server.
timeout (int): timeout in seconds on initial connect to server (default config.TIMEOUT). timeout (int, optional): Timeout in seconds on initial connection to the server
(default config.TIMEOUT).
Attributes: Attributes:
allowCameraUpload (bool): True if server allows camera upload. 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._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
self._session = session or requests.Session() self._session = session or requests.Session()
self._timeout = timeout
self._library = None # cached library self._library = None # cached library
self._settings = None # cached settings self._settings = None # cached settings
self._myPlexAccount = None # cached myPlexAccount self._myPlexAccount = None # cached myPlexAccount
self._systemAccounts = None # cached list of SystemAccount self._systemAccounts = None # cached list of SystemAccount
self._systemDevices = None # cached list of SystemDevice 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) super(PlexServer, self).__init__(self, data, self.key)
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
self._data = data self._data = data
self.allowCameraUpload = cast(bool, data.attrib.get('allowCameraUpload')) self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload'))
self.allowChannelAccess = cast(bool, data.attrib.get('allowChannelAccess')) self.allowChannelAccess = utils.cast(bool, data.attrib.get('allowChannelAccess'))
self.allowMediaDeletion = cast(bool, data.attrib.get('allowMediaDeletion')) self.allowMediaDeletion = utils.cast(bool, data.attrib.get('allowMediaDeletion'))
self.allowSharing = cast(bool, data.attrib.get('allowSharing')) self.allowSharing = utils.cast(bool, data.attrib.get('allowSharing'))
self.allowSync = cast(bool, data.attrib.get('allowSync')) self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
self.backgroundProcessing = cast(bool, data.attrib.get('backgroundProcessing')) self.backgroundProcessing = utils.cast(bool, data.attrib.get('backgroundProcessing'))
self.certificate = cast(bool, data.attrib.get('certificate')) self.certificate = utils.cast(bool, data.attrib.get('certificate'))
self.companionProxy = cast(bool, data.attrib.get('companionProxy')) self.companionProxy = utils.cast(bool, data.attrib.get('companionProxy'))
self.diagnostics = utils.toList(data.attrib.get('diagnostics')) 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.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.machineIdentifier = data.attrib.get('machineIdentifier')
self.multiuser = cast(bool, data.attrib.get('multiuser')) self.multiuser = utils.cast(bool, data.attrib.get('multiuser'))
self.myPlex = cast(bool, data.attrib.get('myPlex')) self.myPlex = utils.cast(bool, data.attrib.get('myPlex'))
self.myPlexMappingState = data.attrib.get('myPlexMappingState') self.myPlexMappingState = data.attrib.get('myPlexMappingState')
self.myPlexSigninState = data.attrib.get('myPlexSigninState') 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.myPlexUsername = data.attrib.get('myPlexUsername')
self.ownerFeatures = utils.toList(data.attrib.get('ownerFeatures')) 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.platform = data.attrib.get('platform')
self.platformVersion = data.attrib.get('platformVersion') self.platformVersion = data.attrib.get('platformVersion')
self.pluginHost = cast(bool, data.attrib.get('pluginHost')) self.pluginHost = utils.cast(bool, data.attrib.get('pluginHost'))
self.readOnlyLibraries = cast(int, data.attrib.get('readOnlyLibraries')) self.readOnlyLibraries = utils.cast(int, data.attrib.get('readOnlyLibraries'))
self.requestParametersInCookie = cast(bool, data.attrib.get('requestParametersInCookie')) self.requestParametersInCookie = utils.cast(bool, data.attrib.get('requestParametersInCookie'))
self.streamingBrainVersion = data.attrib.get('streamingBrainVersion') 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.transcoderActiveVideoSessions = int(data.attrib.get('transcoderActiveVideoSessions', 0))
self.transcoderAudio = cast(bool, data.attrib.get('transcoderAudio')) self.transcoderAudio = utils.cast(bool, data.attrib.get('transcoderAudio'))
self.transcoderLyrics = cast(bool, data.attrib.get('transcoderLyrics')) self.transcoderLyrics = utils.cast(bool, data.attrib.get('transcoderLyrics'))
self.transcoderPhoto = cast(bool, data.attrib.get('transcoderPhoto')) self.transcoderPhoto = utils.cast(bool, data.attrib.get('transcoderPhoto'))
self.transcoderSubtitles = cast(bool, data.attrib.get('transcoderSubtitles')) self.transcoderSubtitles = utils.cast(bool, data.attrib.get('transcoderSubtitles'))
self.transcoderVideo = cast(bool, data.attrib.get('transcoderVideo')) self.transcoderVideo = utils.cast(bool, data.attrib.get('transcoderVideo'))
self.transcoderVideoBitrates = utils.toList(data.attrib.get('transcoderVideoBitrates')) self.transcoderVideoBitrates = utils.toList(data.attrib.get('transcoderVideoBitrates'))
self.transcoderVideoQualities = utils.toList(data.attrib.get('transcoderVideoQualities')) self.transcoderVideoQualities = utils.toList(data.attrib.get('transcoderVideoQualities'))
self.transcoderVideoResolutions = utils.toList(data.attrib.get('transcoderVideoResolutions')) self.transcoderVideoResolutions = utils.toList(data.attrib.get('transcoderVideoResolutions'))
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) 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.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): def _headers(self, **kwargs):
""" Returns dict containing base headers for all requests to the server. """ """ Returns dict containing base headers for all requests to the server. """
@ -166,6 +169,9 @@ class PlexServer(PlexObject):
headers.update(kwargs) headers.update(kwargs)
return headers return headers
def _uriRoot(self):
return 'server://%s/com.plexapp.plugins.library' % self.machineIdentifier
@property @property
def library(self): def library(self):
""" Library to browse or search your media. """ """ Library to browse or search your media. """
@ -193,6 +199,26 @@ class PlexServer(PlexObject):
data = self.query(Account.key) data = self.query(Account.key)
return Account(self, data) 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 @property
def activities(self): def activities(self):
"""Returns all current PMS activities.""" """Returns all current PMS activities."""
@ -209,13 +235,45 @@ class PlexServer(PlexObject):
return self.fetchItems(key) return self.fetchItems(key)
def createToken(self, type='delegation', scope='all'): 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: if not self._token:
# Handle unclaimed servers # Handle unclaimed servers
return None return None
q = self.query('/security/token?type=%s&scope=%s' % (type, scope)) q = self.query('/security/token?type=%s&scope=%s' % (type, scope))
return q.attrib.get('token') 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): def systemAccounts(self):
""" Returns a list of :class:`~plexapi.server.SystemAccount` objects this server contains. """ """ Returns a list of :class:`~plexapi.server.SystemAccount` objects this server contains. """
if self._systemAccounts is None: if self._systemAccounts is None:
@ -357,14 +415,68 @@ class PlexServer(PlexObject):
raise NotFound('Unknown client name: %s' % name) 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`. """ Creates and returns a new :class:`~plexapi.playlist.Playlist`.
Parameters: Parameters:
title (str): Title of the playlist to be created. title (str): Title of the playlist.
items (list<Media>): List of media items to include in 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): def createPlayQueue(self, item, **kwargs):
""" Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`. """ 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'] args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
return results return results
def playlists(self): def playlists(self, playlistType=None):
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """ """ Returns a list of all :class:`~plexapi.playlist.Playlist` objects on the server.
# TODO: Add sort and type options?
# /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0 Parameters:
return self.fetchItems('/playlists') 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): def playlist(self, title):
""" Returns the :class:`~plexapi.client.Playlist` that matches the specified 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') backgroundProcessing = self.fetchItem('/playlists?type=42')
return self.fetchItems('%s/items' % backgroundProcessing.key, cls=Optimized) return self.fetchItems('%s/items' % backgroundProcessing.key, cls=Optimized)
@deprecated('use "plexapi.media.Optimized.items()" instead')
def optimizedItem(self, optimizedID): def optimizedItem(self, optimizedID):
""" Returns single queued optimized item :class:`~plexapi.media.Video` object. """ Returns single queued optimized item :class:`~plexapi.media.Video` object.
Allows for using optimized item ID to connect back to source item. 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)) raise BadRequest('Unknown filter: %s=%s' % (key, value))
if key.startswith('at'): if key.startswith('at'):
try: try:
value = cast(int, value.timestamp()) value = utils.cast(int, value.timestamp())
except AttributeError: except AttributeError:
raise BadRequest('Time frame filter must be a datetime object: %s=%s' % (key, value)) raise BadRequest('Time frame filter must be a datetime object: %s=%s' % (key, value))
elif key.startswith('bytes') or key == 'lan': elif key.startswith('bytes') or key == 'lan':
value = cast(int, value) value = utils.cast(int, value)
elif key == 'accountID': elif key == 'accountID':
if value == self.myPlexAccount().id: if value == self.myPlexAccount().id:
value = 1 # The admin account is accountID=1 value = 1 # The admin account is accountID=1
@ -799,7 +918,7 @@ class Account(PlexObject):
self.privateAddress = data.attrib.get('privateAddress') self.privateAddress = data.attrib.get('privateAddress')
self.privatePort = data.attrib.get('privatePort') self.privatePort = data.attrib.get('privatePort')
self.subscriptionFeatures = utils.toList(data.attrib.get('subscriptionFeatures')) 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') self.subscriptionState = data.attrib.get('subscriptionState')
@ -809,8 +928,8 @@ class Activity(PlexObject):
def _loadData(self, data): def _loadData(self, data):
self._data = data self._data = data
self.cancellable = cast(bool, data.attrib.get('cancellable')) self.cancellable = utils.cast(bool, data.attrib.get('cancellable'))
self.progress = cast(int, data.attrib.get('progress')) self.progress = utils.cast(int, data.attrib.get('progress'))
self.title = data.attrib.get('title') self.title = data.attrib.get('title')
self.subtitle = data.attrib.get('subtitle') self.subtitle = data.attrib.get('subtitle')
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
@ -849,13 +968,13 @@ class SystemAccount(PlexObject):
def _loadData(self, data): def _loadData(self, data):
self._data = 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.defaultAudioLanguage = data.attrib.get('defaultAudioLanguage')
self.defaultSubtitleLanguage = data.attrib.get('defaultSubtitleLanguage') 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.key = data.attrib.get('key')
self.name = data.attrib.get('name') 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') self.thumb = data.attrib.get('thumb')
# For backwards compatibility # For backwards compatibility
self.accountID = self.id self.accountID = self.id
@ -880,7 +999,7 @@ class SystemDevice(PlexObject):
self._data = data self._data = data
self.clientIdentifier = data.attrib.get('clientIdentifier') self.clientIdentifier = data.attrib.get('clientIdentifier')
self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) 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.key = '/devices/%s' % self.id
self.name = data.attrib.get('name') self.name = data.attrib.get('name')
self.platform = data.attrib.get('platform') self.platform = data.attrib.get('platform')
@ -904,12 +1023,12 @@ class StatisticsBandwidth(PlexObject):
def _loadData(self, data): def _loadData(self, data):
self._data = 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.at = utils.toDatetime(data.attrib.get('at'))
self.bytes = cast(int, data.attrib.get('bytes')) self.bytes = utils.cast(int, data.attrib.get('bytes'))
self.deviceID = cast(int, data.attrib.get('deviceID')) self.deviceID = utils.cast(int, data.attrib.get('deviceID'))
self.lan = cast(bool, data.attrib.get('lan')) self.lan = utils.cast(bool, data.attrib.get('lan'))
self.timespan = cast(int, data.attrib.get('timespan')) self.timespan = utils.cast(int, data.attrib.get('timespan'))
def __repr__(self): def __repr__(self):
return '<%s>' % ':'.join([p for p in [ return '<%s>' % ':'.join([p for p in [
@ -945,11 +1064,11 @@ class StatisticsResources(PlexObject):
def _loadData(self, data): def _loadData(self, data):
self._data = data self._data = data
self.at = utils.toDatetime(data.attrib.get('at')) self.at = utils.toDatetime(data.attrib.get('at'))
self.hostCpuUtilization = cast(float, data.attrib.get('hostCpuUtilization')) self.hostCpuUtilization = utils.cast(float, data.attrib.get('hostCpuUtilization'))
self.hostMemoryUtilization = cast(float, data.attrib.get('hostMemoryUtilization')) self.hostMemoryUtilization = utils.cast(float, data.attrib.get('hostMemoryUtilization'))
self.processCpuUtilization = cast(float, data.attrib.get('processCpuUtilization')) self.processCpuUtilization = utils.cast(float, data.attrib.get('processCpuUtilization'))
self.processMemoryUtilization = cast(float, data.attrib.get('processMemoryUtilization')) self.processMemoryUtilization = utils.cast(float, data.attrib.get('processMemoryUtilization'))
self.timespan = cast(int, data.attrib.get('timespan')) self.timespan = utils.cast(int, data.attrib.get('timespan'))
def __repr__(self): def __repr__(self):
return '<%s>' % ':'.join([p for p in [ return '<%s>' % ':'.join([p for p in [

View file

@ -164,10 +164,10 @@ class Preferences(Setting):
""" Represents a single Preferences. """ Represents a single Preferences.
Attributes: Attributes:
TAG (str): 'Preferences' TAG (str): 'Setting'
FILTER (str): 'preferences' FILTER (str): 'preferences'
""" """
TAG = 'Preferences' TAG = 'Setting'
FILTER = 'preferences' FILTER = 'preferences'
def _default(self): def _default(self):

View file

@ -200,9 +200,8 @@ def toDatetime(value, format=None):
else: else:
# https://bugs.python.org/issue30684 # https://bugs.python.org/issue30684
# And platform support for before epoch seems to be flaky. # And platform support for before epoch seems to be flaky.
# TODO check for others errors too. # Also limit to max 32-bit integer
if int(value) <= 0: value = min(max(int(value), 86400), 2**31 - 1)
value = 86400
value = datetime.fromtimestamp(int(value)) value = datetime.fromtimestamp(int(value))
return value return value

View file

@ -6,7 +6,7 @@ from plexapi import library, media, utils
from plexapi.base import Playable, PlexPartialObject from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest from plexapi.exceptions import BadRequest
from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin 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 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. 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). guid (str): Plex GUID for the movie, show, season, episode, or clip (plex://movie/5d776b59ad5437001f79c6f8).
key (str): API URL (/library/metadata/<ratingkey>). key (str): API URL (/library/metadata/<ratingkey>).
lastRatedAt (datetime): Datetime the item was last rated.
lastViewedAt (datetime): Datetime the item was last played. lastViewedAt (datetime): Datetime the item was last played.
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID. librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key. librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
@ -35,6 +36,7 @@ class Video(PlexPartialObject):
titleSort (str): Title to use when sorting (defaults to title). titleSort (str): Title to use when sorting (defaults to title).
type (str): 'movie', 'show', 'season', 'episode', or 'clip'. type (str): 'movie', 'show', 'season', 'episode', or 'clip'.
updatedAt (datatime): Datetime the item was updated. 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. viewCount (int): Count of times the item was played.
""" """
@ -47,6 +49,7 @@ class Video(PlexPartialObject):
self.fields = self.findItems(data, media.Field) self.fields = self.findItems(data, media.Field)
self.guid = data.attrib.get('guid') self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key', '') self.key = data.attrib.get('key', '')
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt'))
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionKey = data.attrib.get('librarySectionKey')
@ -60,6 +63,7 @@ class Video(PlexPartialObject):
self.titleSort = data.attrib.get('titleSort', self.title) self.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type') self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) 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)) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0))
@property @property
@ -72,23 +76,32 @@ class Video(PlexPartialObject):
return self._server.url(part, includeToken=True) if part else None return self._server.url(part, includeToken=True) if part else None
def markWatched(self): def markWatched(self):
""" Mark video as watched. """ """ Mark the video as palyed. """
key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
self._server.query(key) self._server.query(key)
self.reload()
def markUnwatched(self): def markUnwatched(self):
""" Mark video unwatched. """ """ Mark the video as unplayed. """
key = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey key = '/:/unscrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey
self._server.query(key) self._server.query(key)
self.reload()
def rate(self, rate): def augmentation(self):
""" Rate video. """ """ Returns a list of :class:`~plexapi.library.Hub` objects.
key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rate) Augmentation returns hub items relating to online media sources
such as Tidal Music "Track from {item}" or "Soundtrack of {item}".
self._server.query(key) Plex Pass and linked Tidal account are required.
self.reload() """
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): def _defaultSyncTitle(self):
""" Returns str, default title for a new syncItem. """ """ Returns str, default title for a new syncItem. """
@ -248,7 +261,7 @@ class Video(PlexPartialObject):
@utils.registerPlexObject @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): CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin):
""" Represents a single Movie. """ 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?). 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 useOriginalTitle (int): Setting that indicates if the original title is used for the movie
(-1 = Library default, 0 = No, 1 = Yes). (-1 = Library default, 0 = No, 1 = Yes).
userRating (float): User rating (2.0; 8.0).
viewOffset (int): View offset in milliseconds. viewOffset (int): View offset in milliseconds.
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
year (int): Year movie was released. 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.studio = data.attrib.get('studio')
self.tagline = data.attrib.get('tagline') self.tagline = data.attrib.get('tagline')
self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) 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.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.writers = self.findItems(data, media.Writer) self.writers = self.findItems(data, media.Writer)
self.year = utils.cast(int, data.attrib.get('year')) 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 """ This does not exist in plex xml response but is added to have a common
interface to get the locations of the movie. interface to get the locations of the movie.
Retruns: Returns:
List<str> of file paths where the movie is found on disk. List<str> of file paths where the movie is found on disk.
""" """
return [part.file for part in self.iterParts() if part] 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): def _prettyfilename(self):
# This is just for compat. # This is just for compat.
return self.title 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): def hubs(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects. """ """ Returns a list of :class:`~plexapi.library.Hub` objects. """
data = self._server.query(self._details_key) data = self._server.query(self._details_key)
video = data.find('Video') return self.findItems(data, library.Hub, rtag='Related')
if video:
related = video.find('Related')
if related:
return self.findItems(related, library.Hub)
def download(self, savepath=None, keep_original_name=False, **kwargs): def download(self, savepath=None, keep_original_name=False, **kwargs):
""" Download video files to specified directory. """ Download video files to specified directory.
@ -381,7 +403,7 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Split
@utils.registerPlexObject @utils.registerPlexObject
class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, SplitMergeMixin, UnmatchMatchMixin, class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin,
CollectionMixin, GenreMixin, LabelMixin): CollectionMixin, GenreMixin, LabelMixin):
""" Represents a single Show (including all seasons and episodes). """ 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/<ratingkey>/theme/<themeid>). theme (str): URL to theme resource (/library/metadata/<ratingkey>/theme/<themeid>).
useOriginalTitle (int): Setting that indicates if the original title is used for the show useOriginalTitle (int): Setting that indicates if the original title is used for the show
(-1 = Library default, 0 = No, 1 = Yes). (-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. viewedLeafCount (int): Number of items marked as played in the show view.
year (int): Year the show was released. 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.tagline = data.attrib.get('tagline')
self.theme = data.attrib.get('theme') self.theme = data.attrib.get('theme')
self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) 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.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year')) self.year = utils.cast(int, data.attrib.get('year'))
@ -492,21 +512,14 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Spl
def hubs(self): def hubs(self):
""" Returns a list of :class:`~plexapi.library.Hub` objects. """ """ Returns a list of :class:`~plexapi.library.Hub` objects. """
data = self._server.query(self._details_key) data = self._server.query(self._details_key)
directory = data.find('Directory') return self.findItems(data, library.Hub, rtag='Related')
if directory:
related = directory.find('Related')
if related:
return self.findItems(related, library.Hub)
def onDeck(self): def onDeck(self):
""" Returns show's On Deck :class:`~plexapi.video.Video` object or `None`. """ Returns show's On Deck :class:`~plexapi.video.Video` object or `None`.
If show is unwatched, return will likely be the first episode. If show is unwatched, return will likely be the first episode.
""" """
data = self._server.query(self._details_key) data = self._server.query(self._details_key)
episode = next(data.iter('OnDeck'), None) return next(iter(self.findItems(data, rtag='OnDeck')), None)
if episode:
return self.findItems(episode)[0]
return None
def season(self, title=None, season=None): def season(self, title=None, season=None):
""" Returns the season with the specified title or number. """ Returns the season with the specified title or number.
@ -518,7 +531,7 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Spl
Raises: Raises:
:exc:`~plexapi.exceptions.BadRequest`: If title or season parameter is missing. :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): if title is not None and not isinstance(title, int):
return self.fetchItem(key, Season, title__iexact=title) return self.fetchItem(key, Season, title__iexact=title)
elif season is not None or isinstance(title, int): elif season is not None or isinstance(title, int):
@ -585,12 +598,13 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Spl
@utils.registerPlexObject @utils.registerPlexObject
class Season(Video, ArtMixin, PosterMixin): class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin):
""" Represents a single Show Season (including all episodes). """ Represents a single Show Season (including all episodes).
Attributes: Attributes:
TAG (str): 'Directory' TAG (str): 'Directory'
TYPE (str): 'season' TYPE (str): 'season'
collections (List<:class:`~plexapi.media.Collection`>): List of collection objects.
guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. guids (List<:class:`~plexapi.media.Guid`>): List of guid objects.
index (int): Season number. index (int): Season number.
key (str): API URL (/library/metadata/<ratingkey>). key (str): API URL (/library/metadata/<ratingkey>).
@ -599,10 +613,12 @@ class Season(Video, ArtMixin, PosterMixin):
parentIndex (int): Plex index number for the show. parentIndex (int): Plex index number for the show.
parentKey (str): API URL of the show (/library/metadata/<parentRatingKey>). parentKey (str): API URL of the show (/library/metadata/<parentRatingKey>).
parentRatingKey (int): Unique key identifying the show. parentRatingKey (int): Unique key identifying the show.
parentStudio (str): Studio that created show.
parentTheme (str): URL to show theme resource (/library/metadata/<parentRatingkey>/theme/<themeid>). parentTheme (str): URL to show theme resource (/library/metadata/<parentRatingkey>/theme/<themeid>).
parentThumb (str): URL to show thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>). parentThumb (str): URL to show thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the show for the season. parentTitle (str): Name of the show for the season.
viewedLeafCount (int): Number of items marked as played in the season view. viewedLeafCount (int): Number of items marked as played in the season view.
year (int): Year the season was released.
""" """
TAG = 'Directory' TAG = 'Directory'
TYPE = 'season' TYPE = 'season'
@ -611,18 +627,21 @@ class Season(Video, ArtMixin, PosterMixin):
def _loadData(self, data): def _loadData(self, data):
""" Load attribute values from Plex XML response. """ """ Load attribute values from Plex XML response. """
Video._loadData(self, data) Video._loadData(self, data)
self.collections = self.findItems(data, media.Collection)
self.guids = self.findItems(data, media.Guid) self.guids = self.findItems(data, media.Guid)
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.key = self.key.replace('/children', '') # FIX_BUG_50 self.key = self.key.replace('/children', '') # FIX_BUG_50
self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.parentGuid = data.attrib.get('parentGuid') 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.parentKey = data.attrib.get('parentKey')
self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentStudio = data.attrib.get('parentStudio')
self.parentTheme = data.attrib.get('parentTheme') self.parentTheme = data.attrib.get('parentTheme')
self.parentThumb = data.attrib.get('parentThumb') self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle') self.parentTitle = data.attrib.get('parentTitle')
self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount'))
self.year = utils.cast(int, data.attrib.get('year'))
def __iter__(self): def __iter__(self):
for episode in self.episodes(): for episode in self.episodes():
@ -642,7 +661,7 @@ class Season(Video, ArtMixin, PosterMixin):
@property @property
def seasonNumber(self): def seasonNumber(self):
""" Returns season number. """ """ Returns the season number. """
return self.index return self.index
def episodes(self, **kwargs): def episodes(self, **kwargs):
@ -661,10 +680,14 @@ class Season(Video, ArtMixin, PosterMixin):
:exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing. :exc:`~plexapi.exceptions.BadRequest`: If title or episode parameter is missing.
""" """
key = '/library/metadata/%s/children' % self.ratingKey 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) return self.fetchItem(key, Episode, title__iexact=title)
elif episode is not None: elif episode is not None or isinstance(title, int):
return self.fetchItem(key, Episode, parentIndex=self.index, index=episode) 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') raise BadRequest('Missing argument: title or episode is required')
def get(self, title=None, episode=None): 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. Will only return a match if the show's On Deck episode is in this season.
""" """
data = self._server.query(self._details_key) data = self._server.query(self._details_key)
episode = next(data.iter('OnDeck'), None) return next(iter(self.findItems(data, rtag='OnDeck')), None)
if episode:
return self.findItems(episode)[0]
return None
def show(self): def show(self):
""" Return the season's :class:`~plexapi.video.Show`. """ """ Return the season's :class:`~plexapi.video.Show`. """
@ -713,8 +733,8 @@ class Season(Video, ArtMixin, PosterMixin):
@utils.registerPlexObject @utils.registerPlexObject
class Episode(Video, Playable, ArtMixin, PosterMixin, class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
DirectorMixin, WriterMixin): CollectionMixin, DirectorMixin, WriterMixin):
""" Represents a single Shows Episode. """ Represents a single Shows Episode.
Attributes: Attributes:
@ -724,6 +744,7 @@ class Episode(Video, Playable, ArtMixin, PosterMixin,
audienceRatingImage (str): Key to audience rating image (tmdb://image.rating). audienceRatingImage (str): Key to audience rating image (tmdb://image.rating).
chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects. chapters (List<:class:`~plexapi.media.Chapter`>): List of Chapter objects.
chapterSource (str): Chapter source (agent; media; mixed). 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). contentRating (str) Content rating (PG-13; NR; TV-G).
directors (List<:class:`~plexapi.media.Director`>): List of director objects. directors (List<:class:`~plexapi.media.Director`>): List of director objects.
duration (int): Duration of the episode in milliseconds. 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. parentRatingKey (int): Unique key identifying the season.
parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>). parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the season for the episode. 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). rating (float): Episode rating (7.9; 9.8; 8.1).
skipParent (bool): True if the show's seasons are set to hidden. 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. viewOffset (int): View offset in milliseconds.
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. 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' TAG = 'Video'
TYPE = 'episode' TYPE = 'episode'
@ -765,6 +786,7 @@ class Episode(Video, Playable, ArtMixin, PosterMixin,
self.audienceRatingImage = data.attrib.get('audienceRatingImage') self.audienceRatingImage = data.attrib.get('audienceRatingImage')
self.chapters = self.findItems(data, media.Chapter) self.chapters = self.findItems(data, media.Chapter)
self.chapterSource = data.attrib.get('chapterSource') self.chapterSource = data.attrib.get('chapterSource')
self.collections = self.findItems(data, media.Collection)
self.contentRating = data.attrib.get('contentRating') self.contentRating = data.attrib.get('contentRating')
self.directors = self.findItems(data, media.Director) self.directors = self.findItems(data, media.Director)
self.duration = utils.cast(int, data.attrib.get('duration')) 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.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self.parentThumb = data.attrib.get('parentThumb') self.parentThumb = data.attrib.get('parentThumb')
self.parentTitle = data.attrib.get('parentTitle') 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.rating = utils.cast(float, data.attrib.get('rating'))
self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) 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.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.writers = self.findItems(data, media.Writer) self.writers = self.findItems(data, media.Writer)
self.year = utils.cast(int, data.attrib.get('year')) 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 """ This does not exist in plex xml response but is added to have a common
interface to get the locations of the episode. interface to get the locations of the episode.
Retruns: Returns:
List<str> of file paths where the episode is found on disk. List<str> of file paths where the episode is found on disk.
""" """
return [part.file for part in self.iterParts() if part] return [part.file for part in self.iterParts() if part]
@property
def episodeNumber(self):
""" Returns the episode number. """
return self.index
@property @property
def seasonNumber(self): def seasonNumber(self):
""" Returns the episodes season number. """ """ Returns the episode's season number. """
if self._seasonNumber is None: if self._seasonNumber is None:
self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber self._seasonNumber = self.parentIndex if self.parentIndex else self.season().seasonNumber
return utils.cast(int, self._seasonNumber) return utils.cast(int, self._seasonNumber)
@property @property
def seasonEpisode(self): def seasonEpisode(self):
""" Returns the s00e00 string containing the season and episode. """ """ Returns the s00e00 string containing the season and episode numbers. """
return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.index).zfill(2)) return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.episodeNumber).zfill(2))
@property @property
def hasIntroMarker(self): def hasIntroMarker(self):
""" Returns True if the episode has an intro marker in the xml. """ """ Returns True if the episode has an intro marker in the xml. """
if not self.isFullObject():
self.reload()
return any(marker.type == 'intro' for marker in self.markers) 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): def season(self):
"""" Return the episode's :class:`~plexapi.video.Season`. """ """" Return the episode's :class:`~plexapi.video.Season`. """
return self.fetchItem(self.parentKey) return self.fetchItem(self.parentKey)
@ -876,7 +906,6 @@ class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin):
viewOffset (int): View offset in milliseconds. viewOffset (int): View offset in milliseconds.
year (int): Year clip was released. year (int): Year clip was released.
""" """
TAG = 'Video' TAG = 'Video'
TYPE = 'clip' TYPE = 'clip'
METADATA_TYPE = 'clip' METADATA_TYPE = 'clip'
@ -886,11 +915,13 @@ class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin):
Video._loadData(self, data) Video._loadData(self, data)
Playable._loadData(self, data) Playable._loadData(self, data)
self._data = data self._data = data
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.duration = utils.cast(int, data.attrib.get('duration')) self.duration = utils.cast(int, data.attrib.get('duration'))
self.extraType = utils.cast(int, data.attrib.get('extraType')) self.extraType = utils.cast(int, data.attrib.get('extraType'))
self.index = utils.cast(int, data.attrib.get('index')) self.index = utils.cast(int, data.attrib.get('index'))
self.media = self.findItems(data, media.Media) 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.skipDetails = utils.cast(int, data.attrib.get('skipDetails'))
self.subtype = data.attrib.get('subtype') self.subtype = data.attrib.get('subtype')
self.thumbAspectRatio = data.attrib.get('thumbAspectRatio') 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 """ This does not exist in plex xml response but is added to have a common
interface to get the locations of the clip. interface to get the locations of the clip.
Retruns: Returns:
List<str> of file paths where the clip is found on disk. List<str> of file paths where the clip is found on disk.
""" """
return [part.file for part in self.iterParts() if part] 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)