Update PlexAPI to 4.6.1

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

View file

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

View file

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

View file

@ -2,13 +2,14 @@
import re
import weakref
from urllib.parse import quote_plus, urlencode
from xml.etree import ElementTree
from plexapi import log, utils
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
from plexapi.utils import tag_plural, tag_helper
DONT_RELOAD_FOR_KEYS = {'key', 'session'}
DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'}
USER_DONT_RELOAD_FOR_KEYS = set()
_DONT_RELOAD_FOR_KEYS = {'key', 'session'}
_DONT_OVERWRITE_SESSION_KEYS = {'usernames', 'players', 'transcodeSessions', 'session'}
OPERATORS = {
'exact': lambda v, q: v == q,
'iexact': lambda v, q: v.lower() == q.lower(),
@ -47,11 +48,12 @@ class PlexObject(object):
self._server = server
self._data = data
self._initpath = initpath or self.key
self._parent = weakref.ref(parent) if parent else None
self._parent = weakref.ref(parent) if parent is not None else None
self._details_key = None
if data is not None:
self._loadData(data)
self._details_key = self._buildDetailsKey()
self._autoReload = False
def __repr__(self):
uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri'))
@ -60,10 +62,12 @@ class PlexObject(object):
def __setattr__(self, attr, value):
# Don't overwrite session specific attr with []
if attr in DONT_OVERWRITE_SESSION_KEYS and value == []:
if attr in _DONT_OVERWRITE_SESSION_KEYS and value == []:
value = getattr(self, attr, [])
# Don't overwrite an attr with None unless it's a private variable
if value is not None or attr.startswith('_') or attr not in self.__dict__:
autoReload = self.__dict__.get('_autoReload')
# Don't overwrite an attr with None unless it's a private variable or not auto reload
if value is not None or attr.startswith('_') or attr not in self.__dict__ or not autoReload:
self.__dict__[attr] = value
def _clean(self, value):
@ -130,6 +134,19 @@ class PlexObject(object):
return True
return False
def _manuallyLoadXML(self, xml, cls=None):
""" Manually load an XML string as a :class:`~plexapi.base.PlexObject`.
Parameters:
xml (str): The XML string to load.
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
items to be fetched, passing this in will help the parser ensure
it only returns those items. By default we convert the xml elements
with the best guess PlexObjects based on tag and type attrs.
"""
elem = ElementTree.fromstring(xml)
return self._buildItemOrNone(elem, cls)
def fetchItem(self, ekey, cls=None, **kwargs):
""" Load the specified key to find and build the first item with the
specified tag and attrs. If no tag or attrs are specified then
@ -249,7 +266,7 @@ class PlexObject(object):
item.librarySectionID = librarySectionID
return items
def findItems(self, data, cls=None, initpath=None, **kwargs):
def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs):
""" Load the specified data to find and build all items with the specified tag
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
on how this is used.
@ -259,6 +276,9 @@ class PlexObject(object):
kwargs['etag'] = cls.TAG
if cls and cls.TYPE and 'type' not in kwargs:
kwargs['type'] = cls.TYPE
# rtag to iter on a specific root tag
if rtag:
data = next(data.iter(rtag), [])
# loop through all data elements to find matches
items = []
for elem in data:
@ -275,9 +295,12 @@ class PlexObject(object):
if value is not None:
return value
def listAttrs(self, data, attr, **kwargs):
def listAttrs(self, data, attr, rtag=None, **kwargs):
""" Return a list of values from matching attribute. """
results = []
# rtag to iter on a specific root tag
if rtag:
data = next(data.iter(rtag), [])
for elem in data:
kwargs['%s__exists' % attr] = True
if self._checkAttrs(elem, **kwargs):
@ -315,13 +338,19 @@ class PlexObject(object):
movie.isFullObject() # Returns True
"""
return self._reload(key=key, **kwargs)
def _reload(self, key=None, _autoReload=False, **kwargs):
""" Perform the actual reload. """
details_key = self._buildDetailsKey(**kwargs) if kwargs else self._details_key
key = key or details_key or self.key
if not key:
raise Unsupported('Cannot reload an object not built from a URL.')
self._initpath = key
data = self._server.query(key)
self._autoReload = _autoReload
self._loadData(data[0])
self._autoReload = False
return self
def _checkAttrs(self, elem, **kwargs):
@ -427,8 +456,9 @@ class PlexPartialObject(PlexObject):
# Dragons inside.. :-/
value = super(PlexPartialObject, self).__getattribute__(attr)
# Check a few cases where we dont want to reload
if attr in DONT_RELOAD_FOR_KEYS: return value
if attr in DONT_OVERWRITE_SESSION_KEYS: return value
if attr in _DONT_RELOAD_FOR_KEYS: return value
if attr in _DONT_OVERWRITE_SESSION_KEYS: return value
if attr in USER_DONT_RELOAD_FOR_KEYS: return value
if attr.startswith('_'): return value
if value not in (None, []): return value
if self.isFullObject(): return value
@ -438,7 +468,7 @@ class PlexPartialObject(PlexObject):
objname = "%s '%s'" % (clsname, title) if title else clsname
log.debug("Reloading %s for attr '%s'", objname, attr)
# Reload and return the value
self.reload()
self._reload(_autoReload=True)
return super(PlexPartialObject, self).__getattribute__(attr)
def analyze(self):
@ -464,7 +494,7 @@ class PlexPartialObject(PlexObject):
self._server.query(key, method=self._server._session.put)
def isFullObject(self):
""" Retruns True if this is already a full object. A full object means all attributes
""" Returns True if this is already a full object. A full object means all attributes
were populated from the api path representing only this item. For example, the
search result for a movie often only contain a portion of the attributes a full
object (main url) for that movie would contain.
@ -507,9 +537,9 @@ class PlexPartialObject(PlexObject):
"""
if not isinstance(items, list):
items = [items]
value = getattr(self, tag_plural(tag))
value = getattr(self, utils.tag_plural(tag))
existing_tags = [t.tag for t in value if t and remove is False]
tag_edits = tag_helper(tag, existing_tags + items, locked, remove)
tag_edits = utils.tag_helper(tag, existing_tags + items, locked, remove)
self.edit(**tag_edits)
def refresh(self):
@ -594,7 +624,7 @@ class Playable(object):
Raises:
:exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL.
"""
if self.TYPE not in ('movie', 'episode', 'track'):
if self.TYPE not in ('movie', 'episode', 'track', 'clip'):
raise Unsupported('Fetching stream URL for %s is unsupported.' % self.TYPE)
mvb = params.get('maxVideoBitrate')
vr = params.get('videoResolution', '')
@ -680,7 +710,7 @@ class Playable(object):
key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey,
time, state)
self._server.query(key)
self.reload()
self._reload(_autoReload=True)
def updateTimeline(self, time, state='stopped', duration=None):
""" Set the timeline progress for this video.
@ -698,4 +728,35 @@ class Playable(object):
key = '/:/timeline?ratingKey=%s&key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s%s'
key %= (self.ratingKey, self.key, time, state, durationStr)
self._server.query(key)
self.reload()
self._reload(_autoReload=True)
class MediaContainer(PlexObject):
""" Represents a single MediaContainer.
Attributes:
TAG (str): 'MediaContainer'
allowSync (int): Sync/Download is allowed/disallowed for feature.
augmentationKey (str): API URL (/library/metadata/augmentations/<augmentationKey>).
identifier (str): "com.plexapp.plugins.library"
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
librarySectionUUID (str): :class:`~plexapi.library.LibrarySection` UUID.
mediaTagPrefix (str): "/system/bundle/media/flags/"
mediaTagVersion (int): Unknown
size (int): The number of items in the hub.
"""
TAG = 'MediaContainer'
def _loadData(self, data):
self._data = data
self.allowSync = utils.cast(int, data.attrib.get('allowSync'))
self.augmentationKey = data.attrib.get('augmentationKey')
self.identifier = data.attrib.get('identifier')
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
self.librarySectionUUID = data.attrib.get('librarySectionUUID')
self.mediaTagPrefix = data.attrib.get('mediaTagPrefix')
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
self.size = utils.cast(int, data.attrib.get('size'))

View file

@ -1,15 +1,18 @@
# -*- coding: utf-8 -*-
from urllib.parse import quote_plus
from plexapi import media, utils
from plexapi.base import PlexPartialObject
from plexapi.exceptions import BadRequest
from plexapi.mixins import ArtMixin, PosterMixin
from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection
from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin
from plexapi.mixins import LabelMixin
from plexapi.settings import Setting
from plexapi.playqueue import PlayQueue
from plexapi.utils import deprecated
@utils.registerPlexObject
class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin):
class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin):
""" Represents a single Collection.
Attributes:
@ -29,6 +32,7 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin):
index (int): Plex index number for the collection.
key (str): API URL (/library/metadata/<ratingkey>).
labels (List<:class:`~plexapi.media.Label`>): List of label objects.
lastRatedAt (datetime): Datetime the collection was last rated.
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
librarySectionKey (str): :class:`~plexapi.library.LibrarySection` key.
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
@ -45,12 +49,13 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin):
titleSort (str): Title to use when sorting (defaults to title).
type (str): 'collection'
updatedAt (datatime): Datetime the collection was updated.
userRating (float): Rating of the collection (0.0 - 10.0) equaling (0 stars - 5 stars).
"""
TAG = 'Directory'
TYPE = 'collection'
def _loadData(self, data):
self._data = data
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.art = data.attrib.get('art')
self.artBlurHash = data.attrib.get('artBlurHash')
@ -65,6 +70,7 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin):
self.index = utils.cast(int, data.attrib.get('index'))
self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50
self.labels = self.findItems(data, media.Label)
self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt'))
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
self.librarySectionKey = data.attrib.get('librarySectionKey')
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
@ -81,83 +87,402 @@ class Collections(PlexPartialObject, ArtMixin, PosterMixin, LabelMixin):
self.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type')
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.userRating = utils.cast(float, data.attrib.get('userRating'))
self._items = None # cache for self.items
self._section = None # cache for self.section
def __len__(self): # pragma: no cover
return len(self.items())
def __iter__(self): # pragma: no cover
for item in self.items():
yield item
def __contains__(self, other): # pragma: no cover
return any(i.key == other.key for i in self.items())
def __getitem__(self, key): # pragma: no cover
return self.items()[key]
@property
def listType(self):
""" Returns the listType for the collection. """
if self.isVideo:
return 'video'
elif self.isAudio:
return 'audio'
elif self.isPhoto:
return 'photo'
else:
raise Unsupported('Unexpected collection type')
@property
def metadataType(self):
""" Returns the type of metadata in the collection. """
return self.subtype
@property
def isVideo(self):
""" Returns True if this is a video collection. """
return self.subtype in {'movie', 'show', 'season', 'episode'}
@property
def isAudio(self):
""" Returns True if this is an audio collection. """
return self.subtype in {'artist', 'album', 'track'}
@property
def isPhoto(self):
""" Returns True if this is a photo collection. """
return self.subtype in {'photoalbum', 'photo'}
@property
@deprecated('use "items" instead', stacklevel=3)
def children(self):
return self.items()
def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to.
"""
if self._section is None:
self._section = super(Collection, self).section()
return self._section
def item(self, title):
""" Returns the item in the collection that matches the specified title.
Parameters:
title (str): Title of the item to return.
Raises:
:class:`plexapi.exceptions.NotFound`: When the item is not found in the collection.
"""
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItem(key, title__iexact=title)
for item in self.items():
if item.title.lower() == title.lower():
return item
raise NotFound('Item with title "%s" not found in the collection' % title)
def items(self):
""" Returns a list of all items in the collection. """
key = '/library/metadata/%s/children' % self.ratingKey
return self.fetchItems(key)
if self._items is None:
key = '%s/children' % self.key
items = self.fetchItems(key)
self._items = items
return self._items
def get(self, title):
""" Alias to :func:`~plexapi.library.Collection.item`. """
return self.item(title)
def __len__(self):
return self.childCount
def _preferences(self):
""" Returns a list of :class:`~plexapi.settings.Preferences` objects. """
items = []
data = self._server.query(self._details_key)
for item in data.iter('Setting'):
items.append(Setting(data=item, server=self._server))
return items
def modeUpdate(self, mode=None):
""" Update Collection Mode
""" Update the collection mode advanced setting.
Parameters:
mode: default (Library default)
hide (Hide Collection)
hideItems (Hide Items in this Collection)
showItems (Show this Collection and its Items)
mode (str): One of the following values:
"default" (Library default),
"hide" (Hide Collection),
"hideItems" (Hide Items in this Collection),
"showItems" (Show this Collection and its Items)
Example:
collection = 'plexapi.library.Collections'
.. code-block:: python
collection.updateMode(mode="hide")
"""
mode_dict = {'default': -1,
mode_dict = {
'default': -1,
'hide': 0,
'hideItems': 1,
'showItems': 2}
'showItems': 2
}
key = mode_dict.get(mode)
if key is None:
raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict)))
part = '/library/metadata/%s/prefs?collectionMode=%s' % (self.ratingKey, key)
return self._server.query(part, method=self._server._session.put)
self.editAdvanced(collectionMode=key)
def sortUpdate(self, sort=None):
""" Update Collection Sorting
""" Update the collection order advanced setting.
Parameters:
sort: realease (Order Collection by realease dates)
alpha (Order Collection alphabetically)
custom (Custom collection order)
sort (str): One of the following values:
"realease" (Order Collection by realease dates),
"alpha" (Order Collection alphabetically),
"custom" (Custom collection order)
Example:
colleciton = 'plexapi.library.Collections'
.. code-block:: python
collection.updateSort(mode="alpha")
"""
sort_dict = {'release': 0,
sort_dict = {
'release': 0,
'alpha': 1,
'custom': 2}
'custom': 2
}
key = sort_dict.get(sort)
if key is None:
raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict)))
part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key)
return self._server.query(part, method=self._server._session.put)
self.editAdvanced(collectionSort=key)
def addItems(self, items):
""" Add items to the collection.
Parameters:
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
or :class:`~plexapi.photo.Photo` objects to be added to the collection.
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying to add items to a smart collection.
"""
if self.smart:
raise BadRequest('Cannot add items to a smart collection.')
if items and not isinstance(items, (list, tuple)):
items = [items]
ratingKeys = []
for item in items:
if item.type != self.subtype: # pragma: no cover
raise BadRequest('Can not mix media types when building a collection: %s and %s' %
(self.subtype, item.type))
ratingKeys.append(str(item.ratingKey))
ratingKeys = ','.join(ratingKeys)
uri = '%s/library/metadata/%s' % (self._server._uriRoot(), ratingKeys)
key = '%s/items%s' % (self.key, utils.joinArgs({
'uri': uri
}))
self._server.query(key, method=self._server._session.put)
def removeItems(self, items):
""" Remove items from the collection.
Parameters:
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
or :class:`~plexapi.photo.Photo` objects to be removed from the collection.
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying to remove items from a smart collection.
"""
if self.smart:
raise BadRequest('Cannot remove items from a smart collection.')
if items and not isinstance(items, (list, tuple)):
items = [items]
for item in items:
key = '%s/items/%s' % (self.key, item.ratingKey)
self._server.query(key, method=self._server._session.delete)
def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs):
""" Update the filters for a smart collection.
Parameters:
libtype (str): The specific type of content to filter
(movie, show, season, episode, artist, album, track, photoalbum, photo, collection).
limit (int): Limit the number of items in the collection.
sort (str or list, optional): A string of comma separated sort fields
or a list of sort fields in the format ``column:dir``.
See :func:`~plexapi.library.LibrarySection.search` for more info.
filters (dict): A dictionary of advanced filters.
See :func:`~plexapi.library.LibrarySection.search` for more info.
**kwargs (dict): Additional custom filters to apply to the search results.
See :func:`~plexapi.library.LibrarySection.search` for more info.
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying update filters for a regular collection.
"""
if not self.smart:
raise BadRequest('Cannot update filters for a regular collection.')
section = self.section()
searchKey = section._buildSearchKey(
sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs)
uri = '%s%s' % (self._server._uriRoot(), searchKey)
key = '%s/items%s' % (self.key, utils.joinArgs({
'uri': uri
}))
self._server.query(key, method=self._server._session.put)
def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs):
""" Edit the collection.
Parameters:
title (str, optional): The title of the collection.
titleSort (str, optional): The sort title of the collection.
contentRating (str, optional): The summary of the collection.
summary (str, optional): The summary of the collection.
"""
args = {}
if title is not None:
args['title.value'] = title
args['title.locked'] = 1
if titleSort is not None:
args['titleSort.value'] = titleSort
args['titleSort.locked'] = 1
if contentRating is not None:
args['contentRating.value'] = contentRating
args['contentRating.locked'] = 1
if summary is not None:
args['summary.value'] = summary
args['summary.locked'] = 1
args.update(kwargs)
super(Collection, self).edit(**args)
def delete(self):
""" Delete the collection. """
super(Collection, self).delete()
def playQueue(self, *args, **kwargs):
""" Returns a new :class:`~plexapi.playqueue.PlayQueue` from the collection. """
return PlayQueue.create(self._server, self.items(), *args, **kwargs)
@classmethod
def _create(cls, server, title, section, items):
""" Create a regular collection. """
if not items:
raise BadRequest('Must include items to add when creating new collection.')
if not isinstance(section, LibrarySection):
section = server.library.section(section)
if items and not isinstance(items, (list, tuple)):
items = [items]
itemType = items[0].type
ratingKeys = []
for item in items:
if item.type != itemType: # pragma: no cover
raise BadRequest('Can not mix media types when building a collection.')
ratingKeys.append(str(item.ratingKey))
ratingKeys = ','.join(ratingKeys)
uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys)
key = '/library/collections%s' % utils.joinArgs({
'uri': uri,
'type': utils.searchType(itemType),
'title': title,
'smart': 0,
'sectionId': section.key
})
data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key)
@classmethod
def _createSmart(cls, server, title, section, limit=None, libtype=None, sort=None, filters=None, **kwargs):
""" Create a smart collection. """
if not isinstance(section, LibrarySection):
section = server.library.section(section)
libtype = libtype or section.TYPE
searchKey = section._buildSearchKey(
sort=sort, libtype=libtype, limit=limit, filters=filters, **kwargs)
uri = '%s%s' % (server._uriRoot(), searchKey)
key = '/library/collections%s' % utils.joinArgs({
'uri': uri,
'type': utils.searchType(libtype),
'title': title,
'smart': 1,
'sectionId': section.key
})
data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key)
@classmethod
def create(cls, server, title, section, items=None, smart=False, limit=None,
libtype=None, sort=None, filters=None, **kwargs):
""" Create a collection.
Parameters:
server (:class:`~plexapi.server.PlexServer`): Server to create the collection on.
title (str): Title of the collection.
section (:class:`~plexapi.library.LibrarySection`, str): The library section to create the collection in.
items (List): Regular collections only, list of :class:`~plexapi.audio.Audio`,
:class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the collection.
smart (bool): True to create a smart collection. Default False.
limit (int): Smart collections only, limit the number of items in the collection.
libtype (str): Smart collections only, the specific type of content to filter
(movie, show, season, episode, artist, album, track, photoalbum, photo, collection).
sort (str or list, optional): Smart collections only, a string of comma separated sort fields
or a list of sort fields in the format ``column:dir``.
See :func:`~plexapi.library.LibrarySection.search` for more info.
filters (dict): Smart collections only, a dictionary of advanced filters.
See :func:`~plexapi.library.LibrarySection.search` for more info.
**kwargs (dict): Smart collections only, additional custom filters to apply to the
search results. See :func:`~plexapi.library.LibrarySection.search` for more info.
Raises:
:class:`plexapi.exceptions.BadRequest`: When no items are included to create the collection.
:class:`plexapi.exceptions.BadRequest`: When mixing media types in the collection.
Returns:
:class:`~plexapi.collection.Collection`: A new instance of the created Collection.
"""
if smart:
return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs)
else:
return cls._create(server, title, section, items)
def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None,
unwatched=False, title=None):
""" Add the collection as sync item for the specified device.
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
Parameters:
videoQuality (int): idx of quality of the video, one of VIDEO_QUALITY_* values defined in
:mod:`~plexapi.sync` module. Used only when collection contains video.
photoResolution (str): maximum allowed resolution for synchronized photos, see PHOTO_QUALITY_* values in
the module :mod:`~plexapi.sync`. Used only when collection contains photos.
audioBitrate (int): maximum bitrate for synchronized music, better use one of MUSIC_BITRATE_* values
from the module :mod:`~plexapi.sync`. Used only when collection contains audio.
client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see
:func:`~plexapi.myplex.MyPlexAccount.sync`.
clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`.
limit (int): maximum count of items to sync, unlimited if `None`.
unwatched (bool): if `True` watched videos wouldn't be synced.
title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be
generated from metadata of current photo.
Raises:
:exc:`~plexapi.exceptions.BadRequest`: When collection is not allowed to sync.
:exc:`~plexapi.exceptions.Unsupported`: When collection content is unsupported.
Returns:
:class:`~plexapi.sync.SyncItem`: A new instance of the created sync item.
"""
if not self.section().allowSync:
raise BadRequest('The collection is not allowed to sync')
from plexapi.sync import SyncItem, Policy, MediaSettings
myplex = self._server.myPlexAccount()
sync_item = SyncItem(self._server, None)
sync_item.title = title if title else self.title
sync_item.rootTitle = self.title
sync_item.contentType = self.listType
sync_item.metadataType = self.metadataType
sync_item.machineIdentifier = self._server.machineIdentifier
sync_item.location = 'library:///directory/%s' % quote_plus(
'%s/children?excludeAllLeaves=1' % (self.key)
)
sync_item.policy = Policy.create(limit, unwatched)
if self.isVideo:
sync_item.mediaSettings = MediaSettings.createVideo(videoQuality)
elif self.isAudio:
sync_item.mediaSettings = MediaSettings.createMusic(audioBitrate)
elif self.isPhoto:
sync_item.mediaSettings = MediaSettings.createPhoto(photoResolution)
else:
raise Unsupported('Unsupported collection content')
return myplex.sync(sync_item, client=client, clientId=clientId)

View file

@ -219,7 +219,7 @@ class Library(PlexObject):
**Show Preferences**
* **agent** (str): com.plexapp.agents.none, com.plexapp.agents.thetvdb, com.plexapp.agents.themoviedb,
tv.plex.agent.series
tv.plex.agents.series
* **enableBIFGeneration** (bool): Enable video preview thumbnails. Default value true.
* **episodeSort** (int): Episode order. Default -1 Possible options: 0:Oldest first, 1:Newest first.
* **flattenSeasons** (int): Seasons. Default value 0 Possible options: 0:Show,1:Hide.
@ -504,11 +504,10 @@ class LibrarySection(PlexObject):
for settingID, value in kwargs.items():
try:
enums = idEnums.get(settingID)
enumValues = [int(x) for x in enums]
except TypeError:
enums = idEnums[settingID]
except KeyError:
raise NotFound('%s not found in %s' % (value, list(idEnums.keys())))
if value in enumValues:
if value in enums:
data[key % settingID] = value
else:
raise NotFound('%s not found in %s' % (value, enums))
@ -538,13 +537,16 @@ class LibrarySection(PlexObject):
key = '/library/sections/%s/onDeck' % self.key
return self.fetchItems(key)
def recentlyAdded(self, maxresults=50):
def recentlyAdded(self, maxresults=50, libtype=None):
""" Returns a list of media items recently added from this library section.
Parameters:
maxresults (int): Max number of items to return (default 50).
libtype (str, optional): The library type to filter (movie, show, season, episode,
artist, album, track, photoalbum, photo). Default is the main library type.
"""
return self.search(sort='addedAt:desc', maxresults=maxresults)
libtype = libtype or self.TYPE
return self.search(sort='addedAt:desc', maxresults=maxresults, libtype=libtype)
def firstCharacter(self):
key = '/library/sections/%s/firstCharacter' % self.key
@ -596,12 +598,18 @@ class LibrarySection(PlexObject):
""" Retrieves and caches the list of :class:`~plexapi.library.FilteringType` and
list of :class:`~plexapi.library.FilteringFieldType` for this library section.
"""
key = '/library/sections/%s/all?includeMeta=1&X-Plex-Container-Start=0&X-Plex-Container-Size=0' % self.key
_key = ('/library/sections/%s/%s?includeMeta=1&includeAdvanced=1'
'&X-Plex-Container-Start=0&X-Plex-Container-Size=0')
key = _key % (self.key, 'all')
data = self._server.query(key)
meta = data.find('Meta')
if meta:
self._filterTypes = self.findItems(meta, FilteringType)
self._fieldTypes = self.findItems(meta, FilteringFieldType)
self._filterTypes = self.findItems(data, FilteringType, rtag='Meta')
self._fieldTypes = self.findItems(data, FilteringFieldType, rtag='Meta')
if self.TYPE != 'photo': # No collections for photo library
key = _key % (self.key, 'collections')
data = self._server.query(key)
self._filterTypes.extend(self.findItems(data, FilteringType, rtag='Meta'))
def filterTypes(self):
""" Returns a list of available :class:`~plexapi.library.FilteringType` for this library section. """
@ -614,7 +622,7 @@ class LibrarySection(PlexObject):
Parameters:
libtype (str, optional): The library type to filter (movie, show, season, episode,
artist, album, track, photoalbum, photo).
artist, album, track, photoalbum, photo, collection).
Raises:
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype for this library.
@ -659,7 +667,7 @@ class LibrarySection(PlexObject):
Parameters:
libtype (str, optional): The library type to filter (movie, show, season, episode,
artist, album, track, photoalbum, photo).
artist, album, track, photoalbum, photo, collection).
Example:
@ -678,7 +686,7 @@ class LibrarySection(PlexObject):
Parameters:
libtype (str, optional): The library type to filter (movie, show, season, episode,
artist, album, track, photoalbum, photo).
artist, album, track, photoalbum, photo, collection).
Example:
@ -697,7 +705,7 @@ class LibrarySection(PlexObject):
Parameters:
libtype (str, optional): The library type to filter (movie, show, season, episode,
artist, album, track, photoalbum, photo).
artist, album, track, photoalbum, photo, collection).
Example:
@ -740,7 +748,7 @@ class LibrarySection(PlexObject):
field (str): :class:`~plexapi.library.FilteringFilter` object,
or the name of the field (genre, year, contentRating, etc.).
libtype (str, optional): The library type to filter (movie, show, season, episode,
artist, album, track, photoalbum, photo).
artist, album, track, photoalbum, photo, collection).
Raises:
:exc:`~plexapi.exceptions.BadRequest`: Invalid filter field.
@ -783,11 +791,11 @@ class LibrarySection(PlexObject):
libtype = _libtype or libtype or self.TYPE
try:
filterField = next(f for f in self.listFields(libtype) if f.key.endswith(field))
filterField = next(f for f in self.listFields(libtype) if f.key.split('.')[-1] == field)
except StopIteration:
for filterType in reversed(self.filterTypes()):
if filterType.type != libtype:
filterField = next((f for f in filterType.fields if f.key.endswith(field)), None)
filterField = next((f for f in filterType.fields if f.key.split('.')[-1] == field), None)
if filterField:
break
else:
@ -854,7 +862,7 @@ class LibrarySection(PlexObject):
elif fieldType.type == 'date':
value = self._validateFieldValueDate(value)
elif fieldType.type == 'integer':
value = int(value)
value = float(value) if '.' in str(value) else int(value)
elif fieldType.type == 'string':
value = str(value)
elif fieldType.type in choiceTypes:
@ -880,6 +888,19 @@ class LibrarySection(PlexObject):
else:
return int(utils.toDatetime(value, '%Y-%m-%d').timestamp())
def _validateSortFields(self, sort, libtype=None):
""" Validates a list of filter sort fields is available for the library.
Returns the validated comma separated sort fields string.
"""
if isinstance(sort, str):
sort = sort.split(',')
validatedSorts = []
for _sort in sort:
validatedSorts.append(self._validateSortField(_sort.strip(), libtype))
return ','.join(validatedSorts)
def _validateSortField(self, sort, libtype=None):
""" Validates a filter sort field is available for the library.
Returns the validated sort field string.
@ -891,19 +912,19 @@ class LibrarySection(PlexObject):
libtype = _libtype or libtype or self.TYPE
try:
filterSort = next(f for f in self.listSorts(libtype) if f.key.endswith(sortField))
filterSort = next(f for f in self.listSorts(libtype) if f.key == sortField)
except StopIteration:
availableSorts = [f.key for f in self.listSorts(libtype)]
raise NotFound('Unknown sort field "%s" for libtype "%s". '
'Available sort fields: %s'
% (sortField, libtype, availableSorts)) from None
sortField = filterSort.key
sortField = libtype + '.' + filterSort.key
if not sortDir:
sortDir = filterSort.defaultDirection
availableDirections = ['asc', 'desc']
availableDirections = ['asc', 'desc', 'nullsLast']
if sortDir not in availableDirections:
raise NotFound('Unknown sort direction "%s". '
'Available sort directions: %s'
@ -911,28 +932,94 @@ class LibrarySection(PlexObject):
return '%s:%s' % (sortField, sortDir)
def _validateAdvancedSearch(self, filters, libtype):
""" Validates an advanced search filter dictionary.
Returns the list of validated URL encoded parameter strings for the advanced search.
"""
if not isinstance(filters, dict):
raise BadRequest('Filters must be a dictionary.')
validatedFilters = []
for field, values in filters.items():
if field.lower() in {'and', 'or'}:
if len(filters.items()) > 1:
raise BadRequest('Multiple keys in the same dictionary with and/or is not allowed.')
if not isinstance(values, list):
raise BadRequest('Value for and/or keys must be a list of dictionaries.')
validatedFilters.append('push=1')
for value in values:
validatedFilters.extend(self._validateAdvancedSearch(value, libtype))
validatedFilters.append('%s=1' % field.lower())
del validatedFilters[-1]
validatedFilters.append('pop=1')
else:
validatedFilters.append(self._validateFilterField(field, values, libtype))
return validatedFilters
def _buildSearchKey(self, title=None, sort=None, libtype=None, limit=None, filters=None, returnKwargs=False, **kwargs):
""" Returns the validated and formatted search query API key
(``/library/sections/<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):
""" Returns the hub search results for this library. See :func:`plexapi.server.PlexServer.search`
for details and parameters.
"""
return self._server.search(query, mediatype, limit, sectionId=self.key)
def search(self, title=None, sort=None, maxresults=None,
libtype=None, container_start=0, container_size=X_PLEX_CONTAINER_SIZE, **kwargs):
def search(self, title=None, sort=None, maxresults=None, libtype=None,
container_start=0, container_size=X_PLEX_CONTAINER_SIZE, limit=None, filters=None, **kwargs):
""" Search the library. The http requests will be batched in container_size. If you are only looking for the
first <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.
Parameters:
title (str, optional): General string query to search for. Partial string matches are allowed.
sort (str, optional): The sort field in the format ``column:dir``.
sort (str or list, optional): A string of comma separated sort fields or a list of sort fields
in the format ``column:dir``.
See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields.
maxresults (int, optional): Only return the specified number of results.
libtype (str, optional): Return results of a specific type (movie, show, season, episode,
artist, album, track, photoalbum, photo) (e.g. ``libtype='episode'`` will only return
:class:`~plexapi.video.Episode` objects)
artist, album, track, photoalbum, photo, collection) (e.g. ``libtype='episode'`` will only
return :class:`~plexapi.video.Episode` objects)
container_start (int, optional): Default 0.
container_size (int, optional): Default X_PLEX_CONTAINER_SIZE in your config file.
limit (int, optional): Limit the number of results from the filter.
filters (dict, optional): A dictionary of advanced filters. See the details below for more info.
**kwargs (dict): Additional custom filters to apply to the search results.
See the details below for more info.
@ -1016,22 +1103,22 @@ class LibrarySection(PlexObject):
In addition, if the filter does not exist for the default library type it will fallback to the most
specific ``libtype`` available. For example, ``show.unwatched`` does not exists so it will fallback to
``episode.unwatched``. The ``libtype`` prefix cannot be included directly in the function parameters so
the ``**kwargs`` must be provided as a dictionary.
the filters must be provided as a filters dictionary.
Examples:
.. code-block:: python
library.search(**{"show.collection": "Documentary", "episode.inProgress": True})
library.search(**{"artist.genre": "pop", "album.decade": 2000})
library.search(filters={"show.collection": "Documentary", "episode.inProgress": True})
library.search(filters={"artist.genre": "pop", "album.decade": 2000})
# The following three options are identical and will return Episode objects
showLibrary.search(title="Winter is Coming", libtype='episode')
showLibrary.search(libtype='episode', **{"episode.title": "Winter is Coming"})
showLibrary.search(libtype='episode', filters={"episode.title": "Winter is Coming"})
showLibrary.searchEpisodes(title="Winter is Coming")
# The following will search for the episode title but return Show objects
showLibrary.search(**{"episode.title": "Winter is Coming"})
showLibrary.search(filters={"episode.title": "Winter is Coming"})
# The following will fallback to episode.unwatched
showLibrary.search(unwatched=True)
@ -1078,27 +1165,55 @@ class LibrarySection(PlexObject):
* ``=``: ``is``
Operators cannot be included directly in the function parameters so the ``**kwargs``
must be provided as a dictionary. The trailing ``=`` on the operator may be excluded.
Operators cannot be included directly in the function parameters so the filters
must be provided as a filters dictionary. The trailing ``=`` on the operator may be excluded.
Examples:
.. code-block:: python
# Genre is horror AND thriller
library.search(**{"genre&": ["horror", "thriller"]})
library.search(filters={"genre&": ["horror", "thriller"]})
# Director is not Steven Spielberg
library.search(**{"director!": "Steven Spielberg"})
library.search(filters={"director!": "Steven Spielberg"})
# Title starts with Marvel and added before 2021-01-01
library.search(**{"title<": "Marvel", "addedAt<<": "2021-01-01"})
library.search(filters={"title<": "Marvel", "addedAt<<": "2021-01-01"})
# Added in the last 30 days using relative dates
library.search(**{"addedAt>>": "30d"})
library.search(filters={"addedAt>>": "30d"})
# Collection is James Bond and user rating is greater than 8
library.search(**{"collection": "James Bond", "userRating>>": 8})
library.search(filters={"collection": "James Bond", "userRating>>": 8})
**Using Advanced Filters**
Any of the Plex filters described above can be combined into a single ``filters`` dictionary that mimics
the advanced filters used in Plex Web with a tree of ``and``/``or`` branches. Each level of the tree must
start with ``and`` (Match all of the following) or ``or`` (Match any of the following) as the dictionary
key, and a list of dictionaries with the desired filters as the dictionary value.
The following example matches `this <../_static/images/LibrarySection.search_filters.png>`__ advanced filter
in Plex Web.
Examples:
.. code-block:: python
advancedFilters = {
'and': [ # Match all of the following in this list
{
'or': [ # Match any of the following in this list
{'title': 'elephant'},
{'title': 'bunny'}
]
},
{'year>>': 1990},
{'unwatched': True}
]
}
library.search(filters=advancedFilters)
**Using PlexAPI Operators**
@ -1120,28 +1235,8 @@ class LibrarySection(PlexObject):
library.search(genre="holiday", viewCount__gte=3)
"""
# cleanup the core arguments
args = {}
filter_args = []
for field, values in list(kwargs.items()):
if field.split('__')[-1] not in OPERATORS:
filter_args.append(self._validateFilterField(field, values, libtype))
del kwargs[field]
if title is not None:
if isinstance(title, (list, tuple)):
filter_args.append(self._validateFilterField('title', title, libtype))
else:
args['title'] = title
if sort is not None:
args['sort'] = self._validateSortField(sort, libtype)
if libtype is not None:
args['type'] = utils.searchType(libtype)
joined_args = utils.joinArgs(args).lstrip('?')
joined_filter_args = '&'.join(filter_args) if filter_args else ''
params = '&'.join([joined_args, joined_filter_args]).strip('&')
key = '/library/sections/%s/all?%s' % (self.key, params)
key, kwargs = self._buildSearchKey(
title=title, sort=sort, libtype=libtype, limit=limit, filters=filters, returnKwargs=True, **kwargs)
return self._search(key, maxresults, container_start, container_size, **kwargs)
def _search(self, key, maxresults, container_start, container_size, **kwargs):
@ -1158,7 +1253,7 @@ class LibrarySection(PlexObject):
container_size=container_size, **kwargs)
if not len(subresults):
if offset > self._totalViewSize:
log.info("container_start is higher then the number of items in the library")
log.info("container_start is higher than the number of items in the library")
results.extend(subresults)
@ -1239,15 +1334,6 @@ class LibrarySection(PlexObject):
if not self.allowSync:
raise BadRequest('The requested library is not allowed to sync')
args = {}
filter_args = []
for field, values in kwargs.items():
filter_args.append(self._validateFilterField(field, values, libtype))
if sort is not None:
args['sort'] = self._validateSortField(sort, libtype)
if libtype is not None:
args['type'] = utils.searchType(libtype)
myplex = self._server.myPlexAccount()
sync_item = SyncItem(self._server, None)
sync_item.title = title if title else self.title
@ -1256,10 +1342,7 @@ class LibrarySection(PlexObject):
sync_item.metadataType = self.METADATA_TYPE
sync_item.machineIdentifier = self._server.machineIdentifier
joined_args = utils.joinArgs(args).lstrip('?')
joined_filter_args = '&'.join(filter_args) if filter_args else ''
params = '&'.join([joined_args, joined_filter_args]).strip('&')
key = '/library/sections/%s/all?%s' % (self.key, params)
key = self._buildSearchKey(title=title, sort=sort, libtype=libtype, **kwargs)
sync_item.location = 'library://%s/directory/%s' % (self.uuid, quote_plus(key))
sync_item.policy = policy
@ -1275,9 +1358,24 @@ class LibrarySection(PlexObject):
"""
return self._server.history(maxresults=maxresults, mindate=mindate, librarySectionID=self.key, accountID=1)
@deprecated('use "collections" (plural) instead')
def collection(self, **kwargs):
return self.collections()
def createCollection(self, title, items=None, smart=False, limit=None,
libtype=None, sort=None, filters=None, **kwargs):
""" Alias for :func:`~plexapi.server.PlexServer.createCollection` using this
:class:`~plexapi.library.LibrarySection`.
"""
return self._server.createCollection(
title, section=self, items=items, smart=smart, limit=limit,
libtype=libtype, sort=sort, filters=filters, **kwargs)
def collection(self, title):
""" Returns the collection with the specified title.
Parameters:
title (str): Title of the item to return.
"""
results = self.collections(title__iexact=title)
if results:
return results[0]
def collections(self, **kwargs):
""" Returns a list of collections from this library section.
@ -1285,6 +1383,25 @@ class LibrarySection(PlexObject):
"""
return self.search(libtype='collection', **kwargs)
def createPlaylist(self, title, items=None, smart=False, limit=None,
sort=None, filters=None, **kwargs):
""" Alias for :func:`~plexapi.server.PlexServer.createPlaylist` using this
:class:`~plexapi.library.LibrarySection`.
"""
return self._server.createPlaylist(
title, section=self, items=items, smart=smart, limit=limit,
sort=sort, filters=filters, **kwargs)
def playlist(self, title):
""" Returns the playlist with the specified title.
Parameters:
title (str): Title of the item to return.
"""
results = self.playlists(title__iexact=title)
if results:
return results[0]
def playlists(self, **kwargs):
""" Returns a list of playlists from this library section. """
key = '/playlists?type=15&playlistType=%s&sectionID=%s' % (self.CONTENT_TYPE, self.key)
@ -1315,6 +1432,14 @@ class MovieSection(LibrarySection):
""" Search for a movie. See :func:`~plexapi.library.LibrarySection.search` for usage. """
return self.search(libtype='movie', **kwargs)
def recentlyAddedMovies(self, maxresults=50):
""" Returns a list of recently added movies from this library section.
Parameters:
maxresults (int): Max number of items to return (default 50).
"""
return self.recentlyAdded(maxresults=maxresults, libtype='movie')
def sync(self, videoQuality, limit=None, unwatched=False, **kwargs):
""" Add current Movie library section as sync item for specified device.
See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and
@ -1358,7 +1483,6 @@ class ShowSection(LibrarySection):
TAG (str): 'Directory'
TYPE (str): 'show'
"""
TAG = 'Directory'
TYPE = 'show'
METADATA_TYPE = 'episode'
@ -1376,13 +1500,29 @@ class ShowSection(LibrarySection):
""" Search for an episode. See :func:`~plexapi.library.LibrarySection.search` for usage. """
return self.search(libtype='episode', **kwargs)
def recentlyAdded(self, maxresults=50):
def recentlyAddedShows(self, maxresults=50):
""" Returns a list of recently added shows from this library section.
Parameters:
maxresults (int): Max number of items to return (default 50).
"""
return self.recentlyAdded(maxresults=maxresults, libtype='show')
def recentlyAddedSeasons(self, maxresults=50):
""" Returns a list of recently added seasons from this library section.
Parameters:
maxresults (int): Max number of items to return (default 50).
"""
return self.recentlyAdded(maxresults=maxresults, libtype='season')
def recentlyAddedEpisodes(self, maxresults=50):
""" Returns a list of recently added episodes from this library section.
Parameters:
maxresults (int): Max number of items to return (default 50).
"""
return self.search(sort='episode.addedAt:desc', maxresults=maxresults)
return self.recentlyAdded(maxresults=maxresults, libtype='episode')
def sync(self, videoQuality, limit=None, unwatched=False, **kwargs):
""" Add current Show library section as sync item for specified device.
@ -1429,9 +1569,8 @@ class MusicSection(LibrarySection):
"""
TAG = 'Directory'
TYPE = 'artist'
CONTENT_TYPE = 'audio'
METADATA_TYPE = 'track'
CONTENT_TYPE = 'audio'
def albums(self):
""" Returns a list of :class:`~plexapi.audio.Album` objects in this section. """
@ -1455,6 +1594,30 @@ class MusicSection(LibrarySection):
""" Search for a track. See :func:`~plexapi.library.LibrarySection.search` for usage. """
return self.search(libtype='track', **kwargs)
def recentlyAddedArtists(self, maxresults=50):
""" Returns a list of recently added artists from this library section.
Parameters:
maxresults (int): Max number of items to return (default 50).
"""
return self.recentlyAdded(maxresults=maxresults, libtype='artist')
def recentlyAddedAlbums(self, maxresults=50):
""" Returns a list of recently added albums from this library section.
Parameters:
maxresults (int): Max number of items to return (default 50).
"""
return self.recentlyAdded(maxresults=maxresults, libtype='album')
def recentlyAddedTracks(self, maxresults=50):
""" Returns a list of recently added tracks from this library section.
Parameters:
maxresults (int): Max number of items to return (default 50).
"""
return self.recentlyAdded(maxresults=maxresults, libtype='track')
def sync(self, bitrate, limit=None, **kwargs):
""" Add current Music library section as sync item for specified device.
See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and
@ -1499,8 +1662,8 @@ class PhotoSection(LibrarySection):
"""
TAG = 'Directory'
TYPE = 'photo'
CONTENT_TYPE = 'photo'
METADATA_TYPE = 'photo'
CONTENT_TYPE = 'photo'
def all(self, libtype=None, **kwargs):
""" Returns a list of all items from this library section.
@ -1513,13 +1676,22 @@ class PhotoSection(LibrarySection):
raise NotImplementedError('Collections are not available for a Photo library.')
def searchAlbums(self, title, **kwargs):
""" Search for an album. See :func:`~plexapi.library.LibrarySection.search` for usage. """
""" Search for a photo album. See :func:`~plexapi.library.LibrarySection.search` for usage. """
return self.search(libtype='photoalbum', title=title, **kwargs)
def searchPhotos(self, title, **kwargs):
""" Search for a photo. See :func:`~plexapi.library.LibrarySection.search` for usage. """
return self.search(libtype='photo', title=title, **kwargs)
def recentlyAddedAlbums(self, maxresults=50):
""" Returns a list of recently added photo albums from this library section.
Parameters:
maxresults (int): Max number of items to return (default 50).
"""
# Use search() instead of recentlyAdded() because libtype=None
return self.search(sort='addedAt:desc', maxresults=maxresults)
def sync(self, resolution, limit=None, **kwargs):
""" Add current Music library section as sync item for specified device.
See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting and
@ -1699,6 +1871,12 @@ class HubMediaTag(PlexObject):
self.tagValue = utils.cast(int, data.attrib.get('tagValue'))
self.thumb = data.attrib.get('thumb')
def items(self, *args, **kwargs):
""" Return the list of items within this tag. """
if not self.key:
raise BadRequest('Key is not defined for this tag: %s' % self.tag)
return self.fetchItems(self.key)
@utils.registerPlexObject
class Tag(HubMediaTag):
@ -1822,6 +2000,111 @@ class FilteringType(PlexObject):
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
# Add additional manual sorts and fields which are available
# but not exposed on the Plex server
self.sorts += self._manualSorts()
self.fields += self._manualFields()
def _manualSorts(self):
""" Manually add additional sorts which are available
but not exposed on the Plex server.
"""
# Sorts: key, dir, title
additionalSorts = [
('guid', 'asc', 'Guid'),
('id', 'asc', 'Rating Key'),
('index', 'asc', '%s Number' % self.type.capitalize()),
('random', 'asc', 'Random'),
('summary', 'asc', 'Summary'),
('tagline', 'asc', 'Tagline'),
('updatedAt', 'asc', 'Date Updated')
]
if self.type == 'season':
additionalSorts.extend([
('titleSort', 'asc', 'Title')
])
elif self.type == 'track':
# Don't know what this is but it is valid
additionalSorts.extend([
('absoluteIndex', 'asc', 'Absolute Index')
])
if self.type == 'collection':
additionalSorts.extend([
('addedAt', 'asc', 'Date Added')
])
manualSorts = []
for sortField, sortDir, sortTitle in additionalSorts:
sortXML = ('<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):
""" Represents a single Filter object for a :class:`~plexapi.library.FilteringType`.
@ -1850,6 +2133,9 @@ class FilteringSort(PlexObject):
Attributes:
TAG (str): 'Sort'
active (bool): True if the sort is currently active.
activeDirection (str): The currently active sorting direction.
default (str): The currently active default sorting direction.
defaultDirection (str): The default sorting direction.
descKey (str): The URL key for sorting with desc.
firstCharacterKey (str): API URL path for first character endpoint.
@ -1861,6 +2147,9 @@ class FilteringSort(PlexObject):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.active = utils.cast(bool, data.attrib.get('active', '0'))
self.activeDirection = data.attrib.get('activeDirection')
self.default = data.attrib.get('default')
self.defaultDirection = data.attrib.get('defaultDirection')
self.descKey = data.attrib.get('descKey')
self.firstCharacterKey = data.attrib.get('firstCharacterKey')

View file

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

View file

@ -2,7 +2,7 @@
from urllib.parse import quote_plus, urlencode
from plexapi import media, settings, utils
from plexapi.exceptions import NotFound
from plexapi.exceptions import BadRequest, NotFound
class AdvancedSettingsMixin(object):
@ -10,15 +10,8 @@ class AdvancedSettingsMixin(object):
def preferences(self):
""" Returns a list of :class:`~plexapi.settings.Preferences` objects. """
items = []
data = self._server.query(self._details_key)
for item in data.iter('Preferences'):
for elem in item:
setting = settings.Preferences(data=elem, server=self._server)
setting._initpath = self.key
items.append(setting)
return items
return self.findItems(data, settings.Preferences, rtag='Preferences')
def preference(self, pref):
""" Returns a :class:`~plexapi.settings.Preferences` object for the specified pref.
@ -39,13 +32,18 @@ class AdvancedSettingsMixin(object):
""" Edit a Plex object's advanced settings. """
data = {}
key = '%s/prefs?' % self.key
preferences = {pref.id: list(pref.enumValues.keys()) for pref in self.preferences()}
preferences = {pref.id: pref for pref in self.preferences() if pref.enumValues}
for settingID, value in kwargs.items():
enumValues = preferences.get(settingID)
if value in enumValues:
try:
pref = preferences[settingID]
except KeyError:
raise NotFound('%s not found in %s' % (value, list(preferences.keys())))
enumValues = pref.enumValues
if enumValues.get(value, enumValues.get(str(value))):
data[settingID] = value
else:
raise NotFound('%s not found in %s' % (value, enumValues))
raise NotFound('%s not found in %s' % (value, list(enumValues)))
url = key + urlencode(data)
self._server.query(url, method=self._server._session.put)
@ -187,6 +185,26 @@ class PosterMixin(PosterUrlMixin):
poster.select()
class RatingMixin(object):
""" Mixin for Plex objects that can have user star ratings. """
def rate(self, rating=None):
""" Rate the Plex object. Note: Plex ratings are displayed out of 5 stars (e.g. rating 7.0 = 3.5 stars).
Parameters:
rating (float, optional): Rating from 0 to 10. Exclude to reset the rating.
Raises:
:exc:`~plexapi.exceptions.BadRequest`: If the rating is invalid.
"""
if rating is None:
rating = -1
elif not isinstance(rating, (int, float)) or rating < 0 or rating > 10:
raise BadRequest('Rating must be between 0 to 10.')
key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rating)
self._server.query(key, method=self._server._session.put)
class SplitMergeMixin(object):
""" Mixin for Plex objects that can be split and merged. """

View file

@ -14,7 +14,6 @@ from plexapi.library import LibrarySection
from plexapi.server import PlexServer
from plexapi.sonos import PlexSonosClient
from plexapi.sync import SyncItem, SyncList
from plexapi.utils import joinArgs
from requests.status_codes import _codes as codes
@ -76,6 +75,7 @@ class MyPlexAccount(PlexObject):
REQUESTS = 'https://plex.tv/api/invites/requests' # get
SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth
WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data
OPTOUTS = 'https://plex.tv/api/v2/user/%(userUUID)s/settings/opt_outs' # get
LINK = 'https://plex.tv/api/v2/pins/link' # put
# Hub sections
VOD = 'https://vod.provider.plex.tv/' # get
@ -128,26 +128,16 @@ class MyPlexAccount(PlexObject):
self.title = data.attrib.get('title')
self.username = data.attrib.get('username')
self.uuid = data.attrib.get('uuid')
subscription = data.find('subscription')
subscription = data.find('subscription')
self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active'))
self.subscriptionStatus = subscription.attrib.get('status')
self.subscriptionPlan = subscription.attrib.get('plan')
self.subscriptionFeatures = self.listAttrs(subscription, 'id', etag='feature')
self.subscriptionFeatures = []
for feature in subscription.iter('feature'):
self.subscriptionFeatures.append(feature.attrib.get('id'))
self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role')
roles = data.find('roles')
self.roles = []
if roles is not None:
for role in roles.iter('role'):
self.roles.append(role.attrib.get('id'))
entitlements = data.find('entitlements')
self.entitlements = []
for entitlement in entitlements.iter('entitlement'):
self.entitlements.append(entitlement.attrib.get('id'))
self.entitlements = self.listAttrs(data, 'id', rtag='entitlements', etag='entitlement')
# TODO: Fetch missing MyPlexAccount attributes
self.profile_settings = None
@ -460,7 +450,7 @@ class MyPlexAccount(PlexObject):
if isinstance(allowChannels, dict):
params['filterMusic'] = self._filterDictToStr(filterMusic or {})
if params:
url += joinArgs(params)
url += utils.joinArgs(params)
response_filters = self.query(url, self._session.put)
return response_servers, response_filters
@ -470,6 +460,7 @@ class MyPlexAccount(PlexObject):
Parameters:
username (str): Username, email or id of the user to return.
"""
username = str(username)
for user in self.users():
# Home users don't have email, username etc.
if username.lower() == user.title.lower():
@ -698,6 +689,13 @@ class MyPlexAccount(PlexObject):
elem = ElementTree.fromstring(req.text)
return self.findItems(elem)
def onlineMediaSources(self):
""" Returns a list of user account Online Media Sources settings :class:`~plexapi.myplex.AccountOptOut`
"""
url = self.OPTOUTS % {'userUUID': self.uuid}
elem = self.query(url)
return self.findItems(elem, cls=AccountOptOut, etag='optOut')
def link(self, pin):
""" Link a device to the account using a pin code.
@ -884,13 +882,7 @@ class MyPlexServerShare(PlexObject):
"""
url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id)
data = self._server.query(url)
sections = []
for section in data.iter('Section'):
if ElementTree.iselement(section):
sections.append(Section(self, section, url))
return sections
return self.findItems(data, Section, rtag='SharedServer')
def history(self, maxresults=9999999, mindate=None):
""" Get all Play History for a user in this shared server.
@ -1075,7 +1067,7 @@ class MyPlexDevice(PlexObject):
self.screenDensity = data.attrib.get('screenDensity')
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt'))
self.connections = [connection.attrib.get('uri') for connection in data.iter('Connection')]
self.connections = self.listAttrs(data, 'uri', etag='Connection')
def connect(self, timeout=None):
""" Returns a new :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer`
@ -1341,3 +1333,54 @@ def _chooseConnection(ctype, name, results):
log.debug('Connecting to %s: %s?X-Plex-Token=%s', ctype, results[0]._baseurl, results[0]._token)
return results[0]
raise NotFound('Unable to connect to %s: %s' % (ctype.lower(), name))
class AccountOptOut(PlexObject):
""" Represents a single AccountOptOut
'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs'
Attributes:
TAG (str): optOut
key (str): Online Media Source key
value (str): Online Media Source opt_in, opt_out, or opt_out_managed
"""
TAG = 'optOut'
CHOICES = {'opt_in', 'opt_out', 'opt_out_managed'}
def _loadData(self, data):
self.key = data.attrib.get('key')
self.value = data.attrib.get('value')
def _updateOptOut(self, option):
""" Sets the Online Media Sources option.
Parameters:
option (str): see CHOICES
Raises:
:exc:`~plexapi.exceptions.NotFound`: ``option`` str not found in CHOICES.
"""
if option not in self.CHOICES:
raise NotFound('%s not found in available choices: %s' % (option, self.CHOICES))
url = self._server.OPTOUTS % {'userUUID': self._server.uuid}
params = {'key': self.key, 'value': option}
self._server.query(url, method=self._server._session.post, params=params)
self.value = option # assume query successful and set the value to option
def optIn(self):
""" Sets the Online Media Source to "Enabled". """
self._updateOptOut('opt_in')
def optOut(self):
""" Sets the Online Media Source to "Disabled". """
self._updateOptOut('opt_out')
def optOutManaged(self):
""" Sets the Online Media Source to "Disabled for Managed Users".
Raises:
:exc:`~plexapi.exceptions.BadRequest`: When trying to opt out music.
"""
if self.key == 'tv.plex.provider.music':
raise BadRequest('%s does not have the option to opt out managed users.' % self.key)
self._updateOptOut('opt_out_managed')

View file

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

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
from urllib.parse import quote_plus
import re
from urllib.parse import quote_plus, unquote
from plexapi import utils
from plexapi.base import Playable, PlexPartialObject
@ -7,7 +8,7 @@ from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection
from plexapi.mixins import ArtMixin, PosterMixin
from plexapi.playqueue import PlayQueue
from plexapi.utils import cast, toDatetime
from plexapi.utils import deprecated
@utils.registerPlexObject
@ -20,9 +21,11 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
addedAt (datetime): Datetime the playlist was added to the server.
allowSync (bool): True if you allow syncing playlists.
composite (str): URL to composite image (/playlist/<ratingKey>/composite/<compositeid>)
content (str): The filter URI string for smart playlists.
duration (int): Duration of the playlist in milliseconds.
durationInSeconds (int): Duration of the playlist in seconds.
guid (str): Plex GUID for the playlist (com.plexapp.agents.none://XXXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXX).
icon (str): Icon URI string for smart playlists.
key (str): API URL (/playlist/<ratingkey>).
leafCount (int): Number of items in the playlist view.
playlistType (str): 'audio', 'video', or 'photo'
@ -39,22 +42,25 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
Playable._loadData(self, data)
self.addedAt = toDatetime(data.attrib.get('addedAt'))
self.allowSync = cast(bool, data.attrib.get('allowSync'))
self.addedAt = utils.toDatetime(data.attrib.get('addedAt'))
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
self.composite = data.attrib.get('composite') # url to thumbnail
self.duration = cast(int, data.attrib.get('duration'))
self.durationInSeconds = cast(int, data.attrib.get('durationInSeconds'))
self.content = data.attrib.get('content')
self.duration = utils.cast(int, data.attrib.get('duration'))
self.durationInSeconds = utils.cast(int, data.attrib.get('durationInSeconds'))
self.icon = data.attrib.get('icon')
self.guid = data.attrib.get('guid')
self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50
self.leafCount = cast(int, data.attrib.get('leafCount'))
self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
self.playlistType = data.attrib.get('playlistType')
self.ratingKey = cast(int, data.attrib.get('ratingKey'))
self.smart = cast(bool, data.attrib.get('smart'))
self.ratingKey = utils.cast(int, data.attrib.get('ratingKey'))
self.smart = utils.cast(bool, data.attrib.get('smart'))
self.summary = data.attrib.get('summary')
self.title = data.attrib.get('title')
self.type = data.attrib.get('type')
self.updatedAt = toDatetime(data.attrib.get('updatedAt'))
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self._items = None # cache for self.items
self._section = None # cache for self.section
def __len__(self): # pragma: no cover
return len(self.items())
@ -63,6 +69,12 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
for item in self.items():
yield item
def __contains__(self, other): # pragma: no cover
return any(i.key == other.key for i in self.items())
def __getitem__(self, key): # pragma: no cover
return self.items()[key]
@property
def thumb(self):
""" Alias to self.composite. """
@ -70,6 +82,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
@property
def metadataType(self):
""" Returns the type of metadata in the playlist (movie, track, or photo). """
if self.isVideo:
return 'movie'
elif self.isAudio:
@ -81,27 +94,54 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
@property
def isVideo(self):
""" Returns True if this is a video playlist. """
return self.playlistType == 'video'
@property
def isAudio(self):
""" Returns True if this is an audio playlist. """
return self.playlistType == 'audio'
@property
def isPhoto(self):
""" Returns True if this is a photo playlist. """
return self.playlistType == 'photo'
def __contains__(self, other): # pragma: no cover
return any(i.key == other.key for i in self.items())
def section(self):
""" Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to.
def __getitem__(self, key): # pragma: no cover
return self.items()[key]
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying to get the section for a regular playlist.
:class:`plexapi.exceptions.Unsupported`: When unable to determine the library section.
"""
if not self.smart:
raise BadRequest('Regular playlists are not associated with a library.')
if self._section is None:
# Try to parse the library section from the content URI string
match = re.search(r'/library/sections/(\d+)/all', unquote(self.content or ''))
if match:
sectionKey = int(match.group(1))
self._section = self._server.library.sectionByID(sectionKey)
return self._section
# Try to get the library section from the first item in the playlist
if self.items():
self._section = self.items()[0].section()
return self._section
raise Unsupported('Unable to determine the library section')
return self._section
def item(self, title):
""" Returns the item in the playlist that matches the specified title.
Parameters:
title (str): Title of the item to return.
Raises:
:class:`plexapi.exceptions.NotFound`: When the item is not found in the playlist.
"""
for item in self.items():
if item.title.lower() == title.lower():
@ -111,7 +151,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
def items(self):
""" Returns a list of all items in the playlist. """
if self._items is None:
key = '/playlists/%s/items' % self.ratingKey
key = '%s/items' % self.key
items = self.fetchItems(key)
self._items = items
return self._items
@ -120,74 +160,170 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
""" Alias to :func:`~plexapi.playlist.Playlist.item`. """
return self.item(title)
def _getPlaylistItemID(self, item):
""" Match an item to a playlist item and return the item playlistItemID. """
for _item in self.items():
if _item.ratingKey == item.ratingKey:
return _item.playlistItemID
raise NotFound('Item with title "%s" not found in the playlist' % item.title)
def addItems(self, items):
""" Add items to a playlist. """
if not isinstance(items, (list, tuple)):
""" Add items to the playlist.
Parameters:
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
or :class:`~plexapi.photo.Photo` objects to be added to the playlist.
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying to add items to a smart playlist.
"""
if self.smart:
raise BadRequest('Cannot add items to a smart playlist.')
if items and not isinstance(items, (list, tuple)):
items = [items]
ratingKeys = []
for item in items:
if item.listType != self.playlistType: # pragma: no cover
raise BadRequest('Can not mix media types when building a playlist: %s and %s' %
(self.playlistType, item.listType))
ratingKeys.append(str(item.ratingKey))
uuid = items[0].section().uuid
ratingKeys = ','.join(ratingKeys)
key = '%s/items%s' % (self.key, utils.joinArgs({
'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys)
}))
result = self._server.query(key, method=self._server._session.put)
self.reload()
return result
ratingKeys = ','.join(ratingKeys)
uri = '%s/library/metadata/%s' % (self._server._uriRoot(), ratingKeys)
key = '%s/items%s' % (self.key, utils.joinArgs({
'uri': uri
}))
self._server.query(key, method=self._server._session.put)
@deprecated('use "removeItems" instead', stacklevel=3)
def removeItem(self, item):
""" Remove a file from a playlist. """
key = '%s/items/%s' % (self.key, item.playlistItemID)
result = self._server.query(key, method=self._server._session.delete)
self.reload()
return result
self.removeItems(item)
def removeItems(self, items):
""" Remove items from the playlist.
Parameters:
items (List): List of :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
or :class:`~plexapi.photo.Photo` objects to be removed from the playlist.
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying to remove items from a smart playlist.
:class:`plexapi.exceptions.NotFound`: When the item does not exist in the playlist.
"""
if self.smart:
raise BadRequest('Cannot remove items from a smart playlist.')
if items and not isinstance(items, (list, tuple)):
items = [items]
for item in items:
playlistItemID = self._getPlaylistItemID(item)
key = '%s/items/%s' % (self.key, playlistItemID)
self._server.query(key, method=self._server._session.delete)
def moveItem(self, item, after=None):
""" Move a to a new position in playlist. """
key = '%s/items/%s/move' % (self.key, item.playlistItemID)
""" Move an item to a new position in playlist.
Parameters:
items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
or :class:`~plexapi.photo.Photo` objects to be moved in the playlist.
after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
or :class:`~plexapi.photo.Photo` objects to move the item after in the playlist.
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart playlist.
:class:`plexapi.exceptions.NotFound`: When the item or item after does not exist in the playlist.
"""
if self.smart:
raise BadRequest('Cannot move items in a smart playlist.')
playlistItemID = self._getPlaylistItemID(item)
key = '%s/items/%s/move' % (self.key, playlistItemID)
if after:
key += '?after=%s' % after.playlistItemID
result = self._server.query(key, method=self._server._session.put)
self.reload()
return result
afterPlaylistItemID = self._getPlaylistItemID(after)
key += '?after=%s' % afterPlaylistItemID
self._server.query(key, method=self._server._session.put)
def updateFilters(self, limit=None, sort=None, filters=None, **kwargs):
""" Update the filters for a smart playlist.
Parameters:
limit (int): Limit the number of items in the playlist.
sort (str or list, optional): A string of comma separated sort fields
or a list of sort fields in the format ``column:dir``.
See :func:`~plexapi.library.LibrarySection.search` for more info.
filters (dict): A dictionary of advanced filters.
See :func:`~plexapi.library.LibrarySection.search` for more info.
**kwargs (dict): Additional custom filters to apply to the search results.
See :func:`~plexapi.library.LibrarySection.search` for more info.
Raises:
:class:`plexapi.exceptions.BadRequest`: When trying update filters for a regular playlist.
"""
if not self.smart:
raise BadRequest('Cannot update filters for a regular playlist.')
section = self.section()
searchKey = section._buildSearchKey(
sort=sort, libtype=section.METADATA_TYPE, limit=limit, filters=filters, **kwargs)
uri = '%s%s' % (self._server._uriRoot(), searchKey)
key = '%s/items%s' % (self.key, utils.joinArgs({
'uri': uri
}))
self._server.query(key, method=self._server._session.put)
def edit(self, title=None, summary=None):
""" Edit playlist. """
key = '/library/metadata/%s%s' % (self.ratingKey, utils.joinArgs({'title': title, 'summary': summary}))
result = self._server.query(key, method=self._server._session.put)
self.reload()
return result
""" Edit the playlist.
Parameters:
title (str, optional): The title of the playlist.
summary (str, optional): The summary of the playlist.
"""
args = {}
if title:
args['title'] = title
if summary:
args['summary'] = summary
key = '%s%s' % (self.key, utils.joinArgs(args))
self._server.query(key, method=self._server._session.put)
def delete(self):
""" Delete playlist. """
return self._server.query(self.key, method=self._server._session.delete)
""" Delete the playlist. """
self._server.query(self.key, method=self._server._session.delete)
def playQueue(self, *args, **kwargs):
""" Create a playqueue from this playlist. """
""" Returns a new :class:`~plexapi.playqueue.PlayQueue` from the playlist. """
return PlayQueue.create(self._server, self, *args, **kwargs)
@classmethod
def _create(cls, server, title, items):
""" Create a playlist. """
""" Create a regular playlist. """
if not items:
raise BadRequest('Must include items to add when creating new playlist')
raise BadRequest('Must include items to add when creating new playlist.')
if items and not isinstance(items, (list, tuple)):
items = [items]
listType = items[0].listType
ratingKeys = []
for item in items:
if item.listType != items[0].listType: # pragma: no cover
raise BadRequest('Can not mix media types when building a playlist')
if item.listType != listType: # pragma: no cover
raise BadRequest('Can not mix media types when building a playlist.')
ratingKeys.append(str(item.ratingKey))
ratingKeys = ','.join(ratingKeys)
uuid = items[0].section().uuid
uri = '%s/library/metadata/%s' % (server._uriRoot(), ratingKeys)
key = '/playlists%s' % utils.joinArgs({
'uri': 'library://%s/directory//library/metadata/%s' % (uuid, ratingKeys),
'type': items[0].listType,
'uri': uri,
'type': listType,
'title': title,
'smart': 0
})
@ -195,54 +331,15 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
return cls(server, data, initpath=key)
@classmethod
def create(cls, server, title, items=None, section=None, limit=None, smart=False, **kwargs):
"""Create a playlist.
Parameters:
server (:class:`~plexapi.server.PlexServer`): Server your connected to.
title (str): Title of the playlist.
items (Iterable): Iterable of objects that should be in the playlist.
section (:class:`~plexapi.library.LibrarySection`, str):
limit (int): default None.
smart (bool): default False.
**kwargs (dict): is passed to the filters. For a example see the search method.
Raises:
:class:`plexapi.exceptions.BadRequest`: when no items are included in create request.
Returns:
:class:`~plexapi.playlist.Playlist`: an instance of created Playlist.
"""
if smart:
return cls._createSmart(server, title, section, limit, **kwargs)
else:
return cls._create(server, title, items)
@classmethod
def _createSmart(cls, server, title, section, limit=None, **kwargs):
""" Create a Smart playlist. """
def _createSmart(cls, server, title, section, limit=None, sort=None, filters=None, **kwargs):
""" Create a smart playlist. """
if not isinstance(section, LibrarySection):
section = server.library.section(section)
sectionType = utils.searchType(section.type)
sectionId = section.key
uuid = section.uuid
uri = 'library://%s/directory//library/sections/%s/all?type=%s' % (uuid,
sectionId,
sectionType)
if limit:
uri = uri + '&limit=%s' % str(limit)
searchKey = section._buildSearchKey(
sort=sort, libtype=section.METADATA_TYPE, limit=limit, filters=filters, **kwargs)
uri = '%s%s' % (server._uriRoot(), searchKey)
for category, value in kwargs.items():
sectionChoices = section.listFilterChoices(category)
for choice in sectionChoices:
if str(choice.title).lower() == str(value).lower():
uri = uri + '&%s=%s' % (category.lower(), str(choice.key))
uri = uri + '&sourceType=%s' % sectionType
key = '/playlists%s' % utils.joinArgs({
'uri': uri,
'type': section.CONTENT_TYPE,
@ -252,20 +349,52 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
data = server.query(key, method=server._session.post)[0]
return cls(server, data, initpath=key)
@classmethod
def create(cls, server, title, section=None, items=None, smart=False, limit=None,
sort=None, filters=None, **kwargs):
""" Create a playlist.
Parameters:
server (:class:`~plexapi.server.PlexServer`): Server to create the playlist on.
title (str): Title of the playlist.
section (:class:`~plexapi.library.LibrarySection`, str): Smart playlists only,
the library section to create the playlist in.
items (List): Regular playlists only, list of :class:`~plexapi.audio.Audio`,
:class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the playlist.
smart (bool): True to create a smart playlist. Default False.
limit (int): Smart playlists only, limit the number of items in the playlist.
sort (str or list, optional): Smart playlists only, a string of comma separated sort fields
or a list of sort fields in the format ``column:dir``.
See :func:`~plexapi.library.LibrarySection.search` for more info.
filters (dict): Smart playlists only, a dictionary of advanced filters.
See :func:`~plexapi.library.LibrarySection.search` for more info.
**kwargs (dict): Smart playlists only, additional custom filters to apply to the
search results. See :func:`~plexapi.library.LibrarySection.search` for more info.
Raises:
:class:`plexapi.exceptions.BadRequest`: When no items are included to create the playlist.
:class:`plexapi.exceptions.BadRequest`: When mixing media types in the playlist.
Returns:
:class:`~plexapi.playlist.Playlist`: A new instance of the created Playlist.
"""
if smart:
return cls._createSmart(server, title, section, limit, sort, filters, **kwargs)
else:
return cls._create(server, title, items)
def copyToUser(self, user):
""" Copy playlist to another user account. """
from plexapi.server import PlexServer
myplex = self._server.myPlexAccount()
user = myplex.user(user)
# Get the token for your machine.
token = user.get_token(self._server.machineIdentifier)
# Login to your server using your friends credentials.
user_server = PlexServer(self._server._baseurl, token)
return self.create(user_server, self.title, self.items())
""" Copy playlist to another user account.
Parameters:
user (str): Username, email or user id of the user to copy the playlist to.
"""
userServer = self._server.switchUser(user)
return self.create(server=userServer, title=self.title, items=self.items())
def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, client=None, clientId=None, limit=None,
unwatched=False, title=None):
""" Add current playlist as sync item for specified device.
""" Add the playlist as a sync item for the specified device.
See :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.
Parameters:
@ -288,9 +417,8 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
:exc:`~plexapi.exceptions.Unsupported`: When playlist content is unsupported.
Returns:
:class:`~plexapi.sync.SyncItem`: an instance of created syncItem.
:class:`~plexapi.sync.SyncItem`: A new instance of the created sync item.
"""
if not self.allowSync:
raise BadRequest('The playlist is not allowed to sync')

View file

@ -9,13 +9,14 @@ from plexapi import utils
from plexapi.alert import AlertListener
from plexapi.base import PlexObject
from plexapi.client import PlexClient
from plexapi.collection import Collection
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
from plexapi.library import Hub, Library, Path, File
from plexapi.media import Conversion, Optimized
from plexapi.playlist import Playlist
from plexapi.playqueue import PlayQueue
from plexapi.settings import Settings
from plexapi.utils import cast, deprecated
from plexapi.utils import deprecated
from requests.status_codes import _codes as codes
# Need these imports to populate utils.PLEXOBJECTS
@ -38,8 +39,9 @@ class PlexServer(PlexObject):
baseurl (str): Base url for to access the Plex Media Server (default: 'http://localhost:32400').
token (str): Required Plex authentication token to access the server.
session (requests.Session, optional): Use your own session object if you want to
cache the http responses from PMS
timeout (int): timeout in seconds on initial connect to server (default config.TIMEOUT).
cache the http responses from the server.
timeout (int, optional): Timeout in seconds on initial connection to the server
(default config.TIMEOUT).
Attributes:
allowCameraUpload (bool): True if server allows camera upload.
@ -105,58 +107,59 @@ class PlexServer(PlexObject):
self._token = logfilter.add_secret(token or CONFIG.get('auth.server_token'))
self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true'
self._session = session or requests.Session()
self._timeout = timeout
self._library = None # cached library
self._settings = None # cached settings
self._myPlexAccount = None # cached myPlexAccount
self._systemAccounts = None # cached list of SystemAccount
self._systemDevices = None # cached list of SystemDevice
data = self.query(self.key, timeout=timeout)
data = self.query(self.key, timeout=self._timeout)
super(PlexServer, self).__init__(self, data, self.key)
def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = data
self.allowCameraUpload = cast(bool, data.attrib.get('allowCameraUpload'))
self.allowChannelAccess = cast(bool, data.attrib.get('allowChannelAccess'))
self.allowMediaDeletion = cast(bool, data.attrib.get('allowMediaDeletion'))
self.allowSharing = cast(bool, data.attrib.get('allowSharing'))
self.allowSync = cast(bool, data.attrib.get('allowSync'))
self.backgroundProcessing = cast(bool, data.attrib.get('backgroundProcessing'))
self.certificate = cast(bool, data.attrib.get('certificate'))
self.companionProxy = cast(bool, data.attrib.get('companionProxy'))
self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload'))
self.allowChannelAccess = utils.cast(bool, data.attrib.get('allowChannelAccess'))
self.allowMediaDeletion = utils.cast(bool, data.attrib.get('allowMediaDeletion'))
self.allowSharing = utils.cast(bool, data.attrib.get('allowSharing'))
self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
self.backgroundProcessing = utils.cast(bool, data.attrib.get('backgroundProcessing'))
self.certificate = utils.cast(bool, data.attrib.get('certificate'))
self.companionProxy = utils.cast(bool, data.attrib.get('companionProxy'))
self.diagnostics = utils.toList(data.attrib.get('diagnostics'))
self.eventStream = cast(bool, data.attrib.get('eventStream'))
self.eventStream = utils.cast(bool, data.attrib.get('eventStream'))
self.friendlyName = data.attrib.get('friendlyName')
self.hubSearch = cast(bool, data.attrib.get('hubSearch'))
self.hubSearch = utils.cast(bool, data.attrib.get('hubSearch'))
self.machineIdentifier = data.attrib.get('machineIdentifier')
self.multiuser = cast(bool, data.attrib.get('multiuser'))
self.myPlex = cast(bool, data.attrib.get('myPlex'))
self.multiuser = utils.cast(bool, data.attrib.get('multiuser'))
self.myPlex = utils.cast(bool, data.attrib.get('myPlex'))
self.myPlexMappingState = data.attrib.get('myPlexMappingState')
self.myPlexSigninState = data.attrib.get('myPlexSigninState')
self.myPlexSubscription = cast(bool, data.attrib.get('myPlexSubscription'))
self.myPlexSubscription = utils.cast(bool, data.attrib.get('myPlexSubscription'))
self.myPlexUsername = data.attrib.get('myPlexUsername')
self.ownerFeatures = utils.toList(data.attrib.get('ownerFeatures'))
self.photoAutoTag = cast(bool, data.attrib.get('photoAutoTag'))
self.photoAutoTag = utils.cast(bool, data.attrib.get('photoAutoTag'))
self.platform = data.attrib.get('platform')
self.platformVersion = data.attrib.get('platformVersion')
self.pluginHost = cast(bool, data.attrib.get('pluginHost'))
self.readOnlyLibraries = cast(int, data.attrib.get('readOnlyLibraries'))
self.requestParametersInCookie = cast(bool, data.attrib.get('requestParametersInCookie'))
self.pluginHost = utils.cast(bool, data.attrib.get('pluginHost'))
self.readOnlyLibraries = utils.cast(int, data.attrib.get('readOnlyLibraries'))
self.requestParametersInCookie = utils.cast(bool, data.attrib.get('requestParametersInCookie'))
self.streamingBrainVersion = data.attrib.get('streamingBrainVersion')
self.sync = cast(bool, data.attrib.get('sync'))
self.sync = utils.cast(bool, data.attrib.get('sync'))
self.transcoderActiveVideoSessions = int(data.attrib.get('transcoderActiveVideoSessions', 0))
self.transcoderAudio = cast(bool, data.attrib.get('transcoderAudio'))
self.transcoderLyrics = cast(bool, data.attrib.get('transcoderLyrics'))
self.transcoderPhoto = cast(bool, data.attrib.get('transcoderPhoto'))
self.transcoderSubtitles = cast(bool, data.attrib.get('transcoderSubtitles'))
self.transcoderVideo = cast(bool, data.attrib.get('transcoderVideo'))
self.transcoderAudio = utils.cast(bool, data.attrib.get('transcoderAudio'))
self.transcoderLyrics = utils.cast(bool, data.attrib.get('transcoderLyrics'))
self.transcoderPhoto = utils.cast(bool, data.attrib.get('transcoderPhoto'))
self.transcoderSubtitles = utils.cast(bool, data.attrib.get('transcoderSubtitles'))
self.transcoderVideo = utils.cast(bool, data.attrib.get('transcoderVideo'))
self.transcoderVideoBitrates = utils.toList(data.attrib.get('transcoderVideoBitrates'))
self.transcoderVideoQualities = utils.toList(data.attrib.get('transcoderVideoQualities'))
self.transcoderVideoResolutions = utils.toList(data.attrib.get('transcoderVideoResolutions'))
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.updater = cast(bool, data.attrib.get('updater'))
self.updater = utils.cast(bool, data.attrib.get('updater'))
self.version = data.attrib.get('version')
self.voiceSearch = cast(bool, data.attrib.get('voiceSearch'))
self.voiceSearch = utils.cast(bool, data.attrib.get('voiceSearch'))
def _headers(self, **kwargs):
""" Returns dict containing base headers for all requests to the server. """
@ -166,6 +169,9 @@ class PlexServer(PlexObject):
headers.update(kwargs)
return headers
def _uriRoot(self):
return 'server://%s/com.plexapp.plugins.library' % self.machineIdentifier
@property
def library(self):
""" Library to browse or search your media. """
@ -193,6 +199,26 @@ class PlexServer(PlexObject):
data = self.query(Account.key)
return Account(self, data)
def claim(self, account):
""" Claim the Plex server using a :class:`~plexapi.myplex.MyPlexAccount`.
This will only work with an unclaimed server on localhost or the same subnet.
Parameters:
account (:class:`~plexapi.myplex.MyPlexAccount`): The account used to
claim the server.
"""
key = '/myplex/claim'
params = {'token': account.claimToken()}
data = self.query(key, method=self._session.post, params=params)
return Account(self, data)
def unclaim(self):
""" Unclaim the Plex server. This will remove the server from your
:class:`~plexapi.myplex.MyPlexAccount`.
"""
data = self.query(Account.key, method=self._session.delete)
return Account(self, data)
@property
def activities(self):
"""Returns all current PMS activities."""
@ -216,6 +242,38 @@ class PlexServer(PlexObject):
q = self.query('/security/token?type=%s&scope=%s' % (type, scope))
return q.attrib.get('token')
def switchUser(self, username, session=None, timeout=None):
""" Returns a new :class:`~plexapi.server.PlexServer` object logged in as the given username.
Note: Only the admin account can switch to other users.
Parameters:
username (str): Username, email or user id of the user to log in to the server.
session (requests.Session, optional): Use your own session object if you want to
cache the http responses from the server. This will default to the same
session as the admin account if no new session is provided.
timeout (int, optional): Timeout in seconds on initial connection to the server.
This will default to the same timeout as the admin account if no new timeout
is provided.
Example:
.. code-block:: python
from plexapi.server import PlexServer
# Login to the Plex server using the admin token
plex = PlexServer('http://plexserver:32400', token='2ffLuB84dqLswk9skLos')
# Login to the same Plex server using a different account
userPlex = plex.switchUser("Username")
"""
user = self.myPlexAccount().user(username)
userToken = user.get_token(self.machineIdentifier)
if session is None:
session = self._session
if timeout is None:
timeout = self._timeout
return PlexServer(self._baseurl, token=userToken, session=session, timeout=timeout)
def systemAccounts(self):
""" Returns a list of :class:`~plexapi.server.SystemAccount` objects this server contains. """
if self._systemAccounts is None:
@ -357,14 +415,68 @@ class PlexServer(PlexObject):
raise NotFound('Unknown client name: %s' % name)
def createPlaylist(self, title, items=None, section=None, limit=None, smart=None, **kwargs):
def createCollection(self, title, section, items=None, smart=False, limit=None,
libtype=None, sort=None, filters=None, **kwargs):
""" Creates and returns a new :class:`~plexapi.collection.Collection`.
Parameters:
title (str): Title of the collection.
section (:class:`~plexapi.library.LibrarySection`, str): The library section to create the collection in.
items (List): Regular collections only, list of :class:`~plexapi.audio.Audio`,
:class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the collection.
smart (bool): True to create a smart collection. Default False.
limit (int): Smart collections only, limit the number of items in the collection.
libtype (str): Smart collections only, the specific type of content to filter
(movie, show, season, episode, artist, album, track, photoalbum, photo, collection).
sort (str or list, optional): Smart collections only, a string of comma separated sort fields
or a list of sort fields in the format ``column:dir``.
See :func:`~plexapi.library.LibrarySection.search` for more info.
filters (dict): Smart collections only, a dictionary of advanced filters.
See :func:`~plexapi.library.LibrarySection.search` for more info.
**kwargs (dict): Smart collections only, additional custom filters to apply to the
search results. See :func:`~plexapi.library.LibrarySection.search` for more info.
Raises:
:class:`plexapi.exceptions.BadRequest`: When no items are included to create the collection.
:class:`plexapi.exceptions.BadRequest`: When mixing media types in the collection.
Returns:
:class:`~plexapi.collection.Collection`: A new instance of the created Collection.
"""
return Collection.create(
self, title, section, items=items, smart=smart, limit=limit,
libtype=libtype, sort=sort, filters=filters, **kwargs)
def createPlaylist(self, title, section=None, items=None, smart=False, limit=None,
sort=None, filters=None, **kwargs):
""" Creates and returns a new :class:`~plexapi.playlist.Playlist`.
Parameters:
title (str): Title of the playlist to be created.
items (list<Media>): List of media items to include in the playlist.
title (str): Title of the playlist.
section (:class:`~plexapi.library.LibrarySection`, str): Smart playlists only,
library section to create the playlist in.
items (List): Regular playlists only, list of :class:`~plexapi.audio.Audio`,
:class:`~plexapi.video.Video`, or :class:`~plexapi.photo.Photo` objects to be added to the playlist.
smart (bool): True to create a smart playlist. Default False.
limit (int): Smart playlists only, limit the number of items in the playlist.
sort (str or list, optional): Smart playlists only, a string of comma separated sort fields
or a list of sort fields in the format ``column:dir``.
See :func:`~plexapi.library.LibrarySection.search` for more info.
filters (dict): Smart playlists only, a dictionary of advanced filters.
See :func:`~plexapi.library.LibrarySection.search` for more info.
**kwargs (dict): Smart playlists only, additional custom filters to apply to the
search results. See :func:`~plexapi.library.LibrarySection.search` for more info.
Raises:
:class:`plexapi.exceptions.BadRequest`: When no items are included to create the playlist.
:class:`plexapi.exceptions.BadRequest`: When mixing media types in the playlist.
Returns:
:class:`~plexapi.playlist.Playlist`: A new instance of the created Playlist.
"""
return Playlist.create(self, title, items=items, limit=limit, section=section, smart=smart, **kwargs)
return Playlist.create(
self, title, section=section, items=items, smart=smart, limit=limit,
sort=sort, filters=filters, **kwargs)
def createPlayQueue(self, item, **kwargs):
""" Creates and returns a new :class:`~plexapi.playqueue.PlayQueue`.
@ -463,11 +575,17 @@ class PlexServer(PlexObject):
args['X-Plex-Container-Start'] += args['X-Plex-Container-Size']
return results
def playlists(self):
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects saved on the server. """
# TODO: Add sort and type options?
# /playlists/all?type=15&sort=titleSort%3Aasc&playlistType=video&smart=0
return self.fetchItems('/playlists')
def playlists(self, playlistType=None):
""" Returns a list of all :class:`~plexapi.playlist.Playlist` objects on the server.
Parameters:
playlistType (str, optional): The type of playlists to return (audio, video, photo).
Default returns all playlists.
"""
key = '/playlists'
if playlistType:
key = '%s?playlistType=%s' % (key, playlistType)
return self.fetchItems(key)
def playlist(self, title):
""" Returns the :class:`~plexapi.client.Playlist` that matches the specified title.
@ -489,6 +607,7 @@ class PlexServer(PlexObject):
backgroundProcessing = self.fetchItem('/playlists?type=42')
return self.fetchItems('%s/items' % backgroundProcessing.key, cls=Optimized)
@deprecated('use "plexapi.media.Optimized.items()" instead')
def optimizedItem(self, optimizedID):
""" Returns single queued optimized item :class:`~plexapi.media.Video` object.
Allows for using optimized item ID to connect back to source item.
@ -735,11 +854,11 @@ class PlexServer(PlexObject):
raise BadRequest('Unknown filter: %s=%s' % (key, value))
if key.startswith('at'):
try:
value = cast(int, value.timestamp())
value = utils.cast(int, value.timestamp())
except AttributeError:
raise BadRequest('Time frame filter must be a datetime object: %s=%s' % (key, value))
elif key.startswith('bytes') or key == 'lan':
value = cast(int, value)
value = utils.cast(int, value)
elif key == 'accountID':
if value == self.myPlexAccount().id:
value = 1 # The admin account is accountID=1
@ -799,7 +918,7 @@ class Account(PlexObject):
self.privateAddress = data.attrib.get('privateAddress')
self.privatePort = data.attrib.get('privatePort')
self.subscriptionFeatures = utils.toList(data.attrib.get('subscriptionFeatures'))
self.subscriptionActive = cast(bool, data.attrib.get('subscriptionActive'))
self.subscriptionActive = utils.cast(bool, data.attrib.get('subscriptionActive'))
self.subscriptionState = data.attrib.get('subscriptionState')
@ -809,8 +928,8 @@ class Activity(PlexObject):
def _loadData(self, data):
self._data = data
self.cancellable = cast(bool, data.attrib.get('cancellable'))
self.progress = cast(int, data.attrib.get('progress'))
self.cancellable = utils.cast(bool, data.attrib.get('cancellable'))
self.progress = utils.cast(int, data.attrib.get('progress'))
self.title = data.attrib.get('title')
self.subtitle = data.attrib.get('subtitle')
self.type = data.attrib.get('type')
@ -849,13 +968,13 @@ class SystemAccount(PlexObject):
def _loadData(self, data):
self._data = data
self.autoSelectAudio = cast(bool, data.attrib.get('autoSelectAudio'))
self.autoSelectAudio = utils.cast(bool, data.attrib.get('autoSelectAudio'))
self.defaultAudioLanguage = data.attrib.get('defaultAudioLanguage')
self.defaultSubtitleLanguage = data.attrib.get('defaultSubtitleLanguage')
self.id = cast(int, data.attrib.get('id'))
self.id = utils.cast(int, data.attrib.get('id'))
self.key = data.attrib.get('key')
self.name = data.attrib.get('name')
self.subtitleMode = cast(int, data.attrib.get('subtitleMode'))
self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode'))
self.thumb = data.attrib.get('thumb')
# For backwards compatibility
self.accountID = self.id
@ -880,7 +999,7 @@ class SystemDevice(PlexObject):
self._data = data
self.clientIdentifier = data.attrib.get('clientIdentifier')
self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
self.id = cast(int, data.attrib.get('id'))
self.id = utils.cast(int, data.attrib.get('id'))
self.key = '/devices/%s' % self.id
self.name = data.attrib.get('name')
self.platform = data.attrib.get('platform')
@ -904,12 +1023,12 @@ class StatisticsBandwidth(PlexObject):
def _loadData(self, data):
self._data = data
self.accountID = cast(int, data.attrib.get('accountID'))
self.accountID = utils.cast(int, data.attrib.get('accountID'))
self.at = utils.toDatetime(data.attrib.get('at'))
self.bytes = cast(int, data.attrib.get('bytes'))
self.deviceID = cast(int, data.attrib.get('deviceID'))
self.lan = cast(bool, data.attrib.get('lan'))
self.timespan = cast(int, data.attrib.get('timespan'))
self.bytes = utils.cast(int, data.attrib.get('bytes'))
self.deviceID = utils.cast(int, data.attrib.get('deviceID'))
self.lan = utils.cast(bool, data.attrib.get('lan'))
self.timespan = utils.cast(int, data.attrib.get('timespan'))
def __repr__(self):
return '<%s>' % ':'.join([p for p in [
@ -945,11 +1064,11 @@ class StatisticsResources(PlexObject):
def _loadData(self, data):
self._data = data
self.at = utils.toDatetime(data.attrib.get('at'))
self.hostCpuUtilization = cast(float, data.attrib.get('hostCpuUtilization'))
self.hostMemoryUtilization = cast(float, data.attrib.get('hostMemoryUtilization'))
self.processCpuUtilization = cast(float, data.attrib.get('processCpuUtilization'))
self.processMemoryUtilization = cast(float, data.attrib.get('processMemoryUtilization'))
self.timespan = cast(int, data.attrib.get('timespan'))
self.hostCpuUtilization = utils.cast(float, data.attrib.get('hostCpuUtilization'))
self.hostMemoryUtilization = utils.cast(float, data.attrib.get('hostMemoryUtilization'))
self.processCpuUtilization = utils.cast(float, data.attrib.get('processCpuUtilization'))
self.processMemoryUtilization = utils.cast(float, data.attrib.get('processMemoryUtilization'))
self.timespan = utils.cast(int, data.attrib.get('timespan'))
def __repr__(self):
return '<%s>' % ':'.join([p for p in [

View file

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

View file

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

View file

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