mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-07 05:31:15 -07:00
Update PlexAPI to 4.6.1
This commit is contained in:
parent
b0a395ad0b
commit
fec17a7344
14 changed files with 1726 additions and 649 deletions
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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'))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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§ionID=%s' % (self.CONTENT_TYPE, self.key)
|
key = '/playlists?type=15&playlistType=%s§ionID=%s' % (self.CONTENT_TYPE, self.key)
|
||||||
|
@ -1315,6 +1432,14 @@ class MovieSection(LibrarySection):
|
||||||
""" Search for a movie. See :func:`~plexapi.library.LibrarySection.search` for usage. """
|
""" 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')
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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. """
|
||||||
|
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
@ -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 [
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue