plexapi/base.py
class PlexPartialObject(PlexObject):
    """ Not all objects in the Plex listings return the complete list of elements
        for the object. This object will allow you to assume each object is complete,
        and if the specified value you request is None it will fetch the full object
        automatically and update itself.
    """
    _INCLUDES = {
        'checkFiles': 1,
        'includeAllConcerts': 1,
        'includeBandwidths': 1,
        'includeChapters': 1,
        'includeChildren': 1,
        'includeConcerts': 1,
        'includeExternalMedia': 1,
        'includeExtras': 1,
        'includeFields': 'thumbBlurHash,artBlurHash',
        'includeGeolocation': 1,
        'includeLoudnessRamps': 1,
        'includeMarkers': 1,
        'includeOnDeck': 1,
        'includePopularLeaves': 1,
        'includePreferences': 1,
        'includeRelated': 1,
        'includeRelatedCount': 1,
        'includeReviews': 1,
        'includeStations': 1
    }

    def __eq__(self, other):
        return other not in [None, []] and self.key == other.key

    def __hash__(self):
        return hash(repr(self))

    def __iter__(self):
        yield self

    def __getattribute__(self, attr):
        # 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 USER_DONT_RELOAD_FOR_KEYS: return value
        if attr.startswith('_'): return value
        if value not in (None, []): return value
        if self.isFullObject(): return value
        # Log the reload.
        clsname = self.__class__.__name__
        title = self.__dict__.get('title', self.__dict__.get('name'))
        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(_autoReload=True)
        return super(PlexPartialObject, self).__getattribute__(attr)

    def analyze(self):
        """ Tell Plex Media Server to performs analysis on it this item to gather
            information. Analysis includes:

            * Gather Media Properties: All of the media you add to a Library has
                properties that are useful to know–whether it's a video file, a
                music track, or one of your photos (container, codec, resolution, etc).
            * Generate Default Artwork: Artwork will automatically be grabbed from a
                video file. A background image will be pulled out as well as a
                smaller image to be used for poster/thumbnail type purposes.
            * Generate Video Preview Thumbnails: Video preview thumbnails are created,
                if you have that feature enabled. Video preview thumbnails allow
                graphical seeking in some Apps. It's also used in the Plex Web App Now
                Playing screen to show a graphical representation of where playback
                is. Video preview thumbnails creation is a CPU-intensive process akin
                to transcoding the file.
            * Generate intro video markers: Detects show intros, exposing the
                'Skip Intro' button in clients.
        """
        key = '/%s/analyze' % self.key.lstrip('/')
        self._server.query(key, method=self._server._session.put)

    def isFullObject(self):
        """ 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.
        """
        return not self.key or (self._details_key or self.key) == self._initpath

    def isPartialObject(self):
        """ Returns True if this is not a full object. """
        return not self.isFullObject()

    def _edit(self, **kwargs):
        """ Actually edit an object. """
        if 'id' not in kwargs:
            kwargs['id'] = self.ratingKey
        if 'type' not in kwargs:
            kwargs['type'] = utils.searchType(self.type)

        part = '/library/sections/%s/all?%s' % (self.librarySectionID,
                                                urlencode(kwargs))
        self._server.query(part, method=self._server._session.put)

    def edit(self, **kwargs):
        """ Edit an object.

            Parameters:
                kwargs (dict): Dict of settings to edit.

            Example:
                {'type': 1,
                 'id': movie.ratingKey,
                 'collection[0].tag.tag': 'Super',
                 'collection.locked': 0}
        """
        self._edit(**kwargs)

    def _edit_tags(self, tag, items, locked=True, remove=False):
        """ Helper to edit tags.

            Parameters:
                tag (str): Tag name.
                items (list): List of tags to add.
                locked (bool): True to lock the field.
                remove (bool): True to remove the tags in items.
        """
        if not isinstance(items, list):
            items = [items]
        value = getattr(self, utils.tag_plural(tag))
        existing_tags = [t.tag for t in value if t and remove is False]
        tag_edits = utils.tag_helper(tag, existing_tags + items, locked, remove)
        self.edit(**tag_edits)

    def refresh(self):
        """ Refreshing a Library or individual item causes the metadata for the item to be
            refreshed, even if it already has metadata. You can think of refreshing as
            "update metadata for the requested item even if it already has some". You should
            refresh a Library or individual item if:

            * You've changed the Library Metadata Agent.
            * You've added "Local Media Assets" (such as artwork, theme music, external
                subtitle files, etc.)
            * You want to freshen the item posters, summary, etc.
            * There's a problem with the poster image that's been downloaded.
            * Items are missing posters or other downloaded information. This is possible if
                the refresh process is interrupted (the Server is turned off, internet
                connection dies, etc).
        """
        key = '%s/refresh' % self.key
        self._server.query(key, method=self._server._session.put)

    def section(self):
        """ Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
        return self._server.library.sectionByID(self.librarySectionID)

    def delete(self):
        """ Delete a media element. This has to be enabled under settings > server > library in plex webui. """
        try:
            return self._server.query(self.key, method=self._server._session.delete)
        except BadRequest:  # pragma: no cover
            log.error('Failed to delete %s. This could be because you '
                'have not allowed items to be deleted', self.key)
            raise

    def history(self, maxresults=9999999, mindate=None):
        """ Get Play History for a media item.

            Parameters:
                maxresults (int): Only return the specified number of results (optional).
                mindate (datetime): Min datetime to return results from.
        """
        return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey)

    def _getWebURL(self, base=None):
        """ Get the Plex Web URL with the correct parameters.
            Private method to allow overriding parameters from subclasses.
        """
        return self._server._buildWebURL(base=base, endpoint='details', key=self.key)

    def getWebURL(self, base=None):
        """ Returns the Plex Web URL for a media item.

            Parameters:
                base (str): The base URL before the fragment (``#!``).
                    Default is https://app.plex.tv/desktop.
        """
        return self._getWebURL(base=base)
plexapi/library.py
class LibrarySection(PlexObject):
    """ Base class for a single library section.

        Attributes:
            agent (str): The metadata agent used for the library section (com.plexapp.agents.imdb, etc).
            allowSync (bool): True if you allow syncing content from the library section.
            art (str): Background artwork used to respresent the library section.
            composite (str): Composite image used to represent the library section.
            createdAt (datetime): Datetime the library section was created.
            filters (bool): True if filters are available for the library section.
            key (int): Key (or ID) of this library section.
            language (str): Language represented in this section (en, xn, etc).
            locations (List<str>): List of folder paths added to the library section.
            refreshing (bool): True if this section is currently being refreshed.
            scanner (str): Internal scanner used to find media (Plex Movie Scanner, Plex Premium Music Scanner, etc.)
            thumb (str): Thumbnail image used to represent the library section.
            title (str): Name of the library section.
            type (str): Type of content section represents (movie, show, artist, photo).
            updatedAt (datetime): Datetime the library section was last updated.
            uuid (str): Unique id for the section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63)
    """

    def _loadData(self, data):
        self._data = data
        self.agent = data.attrib.get('agent')
        self.allowSync = utils.cast(bool, data.attrib.get('allowSync'))
        self.art = data.attrib.get('art')
        self.composite = data.attrib.get('composite')
        self.createdAt = utils.toDatetime(data.attrib.get('createdAt'))
        self.filters = utils.cast(bool, data.attrib.get('filters'))
        self.key = utils.cast(int, data.attrib.get('key'))
        self.language = data.attrib.get('language')
        self.locations = self.listAttrs(data, 'path', etag='Location')
        self.refreshing = utils.cast(bool, data.attrib.get('refreshing'))
        self.scanner = data.attrib.get('scanner')
        self.thumb = data.attrib.get('thumb')
        self.title = data.attrib.get('title')
        self.type = data.attrib.get('type')
        self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
        self.uuid = data.attrib.get('uuid')
        # Private attrs as we dont want a reload.
        self._filterTypes = None
        self._fieldTypes = None
        self._totalViewSize = None
        self._totalSize = None
        self._totalDuration = None
        self._totalStorage = None

    def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs):
        """ Load the specified key 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.

            Parameters:
                container_start (None, int): offset to get a subset of the data
                container_size (None, int): How many items in data

        """
        url_kw = {}
        if container_start is not None:
            url_kw["X-Plex-Container-Start"] = container_start
        if container_size is not None:
            url_kw["X-Plex-Container-Size"] = container_size

        if ekey is None:
            raise BadRequest('ekey was not provided')
        data = self._server.query(ekey, params=url_kw)

        if '/all' in ekey:
            # totalSize is only included in the xml response
            # if container size is used.
            total_size = data.attrib.get("totalSize") or data.attrib.get("size")
            self._totalViewSize = utils.cast(int, total_size)

        items = self.findItems(data, cls, ekey, **kwargs)

        librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
        if librarySectionID:
            for item in items:
                item.librarySectionID = librarySectionID
        return items

    @property
    def totalSize(self):
        """ Returns the total number of items in the library for the default library type. """
        if self._totalSize is None:
            self._totalSize = self.totalViewSize(includeCollections=False)
        return self._totalSize

    @property
    def totalDuration(self):
        """ Returns the total duration (in milliseconds) of items in the library. """
        if self._totalDuration is None:
            self._getTotalDurationStorage()
        return self._totalDuration

    @property
    def totalStorage(self):
        """ Returns the total storage (in bytes) of items in the library. """
        if self._totalStorage is None:
            self._getTotalDurationStorage()
        return self._totalStorage

    def _getTotalDurationStorage(self):
        """ Queries the Plex server for the total library duration and storage and caches the values. """
        data = self._server.query('/media/providers?includeStorage=1')
        xpath = (
            './MediaProvider[@identifier="com.plexapp.plugins.library"]'
            '/Feature[@type="content"]'
            '/Directory[@id="%s"]'
        ) % self.key
        directory = next(iter(data.findall(xpath)), None)
        if directory:
            self._totalDuration = utils.cast(int, directory.attrib.get('durationTotal'))
            self._totalStorage = utils.cast(int, directory.attrib.get('storageTotal'))

    def totalViewSize(self, libtype=None, includeCollections=True):
        """ Returns the total number of items in the library for a specified libtype.
            The number of items for the default library type will be returned if no libtype is specified.
            (e.g. Specify ``libtype='episode'`` for the total number of episodes
            or ``libtype='albums'`` for the total number of albums.)

            Parameters:
                libtype (str, optional): The type of items to return the total number for (movie, show, season, episode,
                    artist, album, track, photoalbum). Default is the main library type.
                includeCollections (bool, optional): True or False to include collections in the total number.
                    Default is True.
        """
        args = {
            'includeCollections': int(bool(includeCollections)),
            'X-Plex-Container-Start': 0,
            'X-Plex-Container-Size': 0
        }
        if libtype is not None:
            if libtype == 'photo':
                args['clusterZoomLevel'] = 1
            else:
                args['type'] = utils.searchType(libtype)
        part = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args))
        data = self._server.query(part)
        return utils.cast(int, data.attrib.get("totalSize"))

    def delete(self):
        """ Delete a library section. """
        try:
            return self._server.query('/library/sections/%s' % self.key, method=self._server._session.delete)
        except BadRequest:  # pragma: no cover
            msg = 'Failed to delete library %s' % self.key
            msg += 'You may need to allow this permission in your Plex settings.'
            log.error(msg)
            raise

    def reload(self):
        """ Reload the data for the library section. """
        self._server.library._loadSections()
        newLibrary = self._server.library.sectionByID(self.key)
        self.__dict__.update(newLibrary.__dict__)
        return self

    def edit(self, agent=None, **kwargs):
        """ Edit a library. See :class:`~plexapi.library.Library` for example usage.

            Parameters:
                agent (str, optional): The library agent.
                kwargs (dict): Dict of settings to edit.
        """
        if not agent:
            agent = self.agent

        locations = []
        if kwargs.get('location'):
            if isinstance(kwargs['location'], str):
                kwargs['location'] = [kwargs['location']]
            for path in kwargs.pop('location'):
                if not self._server.isBrowsable(path):
                    raise BadRequest('Path: %s does not exist.' % path)
                locations.append(('location', path))

        params = list(kwargs.items()) + locations

        part = '/library/sections/%s?agent=%s&%s' % (self.key, agent, urlencode(params, doseq=True))
        self._server.query(part, method=self._server._session.put)

    def addLocations(self, location):
        """ Add a location to a library.
        
            Parameters:
                location (str or list): A single folder path, list of paths.

            Example:

                .. code-block:: python

                LibrarySection.addLocations('/path/1')
                LibrarySection.addLocations(['/path/1', 'path/2', '/path/3'])
        """
        locations = self.locations
        if isinstance(location, str):
            location = [location]
        for path in location:
            if not self._server.isBrowsable(path):
                raise BadRequest('Path: %s does not exist.' % path)
            locations.append(path)
        self.edit(location=locations)

    def removeLocations(self, location):
        """ Remove a location from a library.
        
            Parameters:
                location (str or list): A single folder path, list of paths.

            Example:

                .. code-block:: python

                LibrarySection.removeLocations('/path/1')
                LibrarySection.removeLocations(['/path/1', 'path/2', '/path/3'])
        """
        locations = self.locations
        if isinstance(location, str):
            location = [location]
        for path in location:
            if path in locations:
                locations.remove(path)
            else:
                raise BadRequest('Path: %s does not exist in the library.' % location)
        if len(locations) == 0:
            raise BadRequest('You are unable to remove all locations from a library.')
        self.edit(location=locations)

    def get(self, title):
        """ Returns the media item with the specified title.

            Parameters:
                title (str): Title of the item to return.

            Raises:
                :exc:`~plexapi.exceptions.NotFound`: The title is not found in the library.
        """
        key = '/library/sections/%s/all?includeGuids=1&title=%s' % (self.key, quote(title, safe=''))
        return self.fetchItem(key, title__iexact=title)

    def getGuid(self, guid):
        """ Returns the media item with the specified external IMDB, TMDB, or TVDB ID.
            Note: Only available for the Plex Movie and Plex TV Series agents.

            Parameters:
                guid (str): The external guid of the item to return.
                    Examples: IMDB ``imdb://tt0944947``, TMDB ``tmdb://1399``, TVDB ``tvdb://121361``.

            Raises:
                :exc:`~plexapi.exceptions.NotFound`: The guid is not found in the library.

            Example:

                .. code-block:: python

                    result1 = library.getGuid('imdb://tt0944947')
                    result2 = library.getGuid('tmdb://1399')
                    result3 = library.getGuid('tvdb://121361')

                    # Alternatively, create your own guid lookup dictionary for faster performance
                    guidLookup = {guid.id: item for item in library.all() for guid in item.guids}
                    result1 = guidLookup['imdb://tt0944947']
                    result2 = guidLookup['tmdb://1399']
                    result3 = guidLookup['tvdb://121361']

        """
        try:
            dummy = self.search(maxresults=1)[0]
            match = dummy.matches(agent=self.agent, title=guid.replace('://', '-'))
            return self.search(guid=match[0].guid)[0]
        except IndexError:
            raise NotFound("Guid '%s' is not found in the library" % guid) from None

    def all(self, libtype=None, **kwargs):
        """ Returns a list of all items from this library section.
            See description of :func:`~plexapi.library.LibrarySection.search()` for details about filtering / sorting.
        """
        libtype = libtype or self.TYPE
        return self.search(libtype=libtype, **kwargs)

    def folders(self):
        """ Returns a list of available :class:`~plexapi.library.Folder` for this library section.
        """
        key = '/library/sections/%s/folder' % self.key
        return self.fetchItems(key, Folder)

    def hubs(self):
        """ Returns a list of available :class:`~plexapi.library.Hub` for this library section.
        """
        key = '/hubs/sections/%s?includeStations=1' % self.key
        return self.fetchItems(key)

    def agents(self):
        """ Returns a list of available :class:`~plexapi.media.Agent` for this library section.
        """
        return self._server.agents(self.type)

    def settings(self):
        """ Returns a list of all library settings. """
        key = '/library/sections/%s/prefs' % self.key
        data = self._server.query(key)
        return self.findItems(data, cls=Setting)

    def editAdvanced(self, **kwargs):
        """ Edit a library's advanced settings. """
        data = {}
        idEnums = {}
        key = 'prefs[%s]'

        for setting in self.settings():
            if setting.type != 'bool':
                idEnums[setting.id] = setting.enumValues
            else:
                idEnums[setting.id] = {0: False, 1: True}

        for settingID, value in kwargs.items():
            try:
                enums = idEnums[settingID]
            except KeyError:
                raise NotFound('%s not found in %s' % (value, list(idEnums.keys())))
            if value in enums:
                data[key % settingID] = value
            else:
                raise NotFound('%s not found in %s' % (value, enums))

        self.edit(**data)

    def defaultAdvanced(self):
        """ Edit all of library's advanced settings to default. """
        data = {}
        key = 'prefs[%s]'
        for setting in self.settings():
            if setting.type == 'bool':
                data[key % setting.id] = int(setting.default)
            else:
                data[key % setting.id] = setting.default

        self.edit(**data)

    def _lockUnlockAllField(self, field, libtype=None, locked=True):
        """ Lock or unlock a field for all items in the library. """
        libtype = libtype or self.TYPE
        args = {
            'type': utils.searchType(libtype),
            '%s.locked' % field: int(locked)
        }
        key = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args))
        self._server.query(key, method=self._server._session.put)

    def lockAllField(self, field, libtype=None):
        """ Lock a field for all items in the library.
        
            Parameters:
                field (str): The field to lock (e.g. thumb, rating, collection).
                libtype (str, optional): The library type to lock (movie, show, season, episode,
                    artist, album, track, photoalbum, photo). Default is the main library type.
        """
        self._lockUnlockAllField(field, libtype=libtype, locked=True)

    def unlockAllField(self, field, libtype=None):
        """ Unlock a field for all items in the library.
        
            Parameters:
                field (str): The field to unlock (e.g. thumb, rating, collection).
                libtype (str, optional): The library type to lock (movie, show, season, episode,
                    artist, album, track, photoalbum, photo). Default is the main library type.
        """
        self._lockUnlockAllField(field, libtype=libtype, locked=False)

    def timeline(self):
        """ Returns a timeline query for this library section. """
        key = '/library/sections/%s/timeline' % self.key
        data = self._server.query(key)
        return LibraryTimeline(self, data)

    def onDeck(self):
        """ Returns a list of media items on deck from this library section. """
        key = '/library/sections/%s/onDeck' % self.key
        return self.fetchItems(key)

    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.
        """
        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
        return self.fetchItems(key, cls=FirstCharacter)

    def analyze(self):
        """ Run an analysis on all of the items in this library section. See
            See :func:`~plexapi.base.PlexPartialObject.analyze` for more details.
        """
        key = '/library/sections/%s/analyze' % self.key
        self._server.query(key, method=self._server._session.put)

    def emptyTrash(self):
        """ If a section has items in the Trash, use this option to empty the Trash. """
        key = '/library/sections/%s/emptyTrash' % self.key
        self._server.query(key, method=self._server._session.put)

    def update(self, path=None):
        """ Scan this section for new media.

            Parameters:
                path (str, optional): Full path to folder to scan.
        """
        key = '/library/sections/%s/refresh' % self.key
        if path is not None:
            key += '?path=%s' % quote_plus(path)
        self._server.query(key)

    def cancelUpdate(self):
        """ Cancel update of this Library Section. """
        key = '/library/sections/%s/refresh' % self.key
        self._server.query(key, method=self._server._session.delete)

    def refresh(self):
        """ Forces a download of fresh media information from the internet.
            This can take a long time. Any locked fields are not modified.
        """
        key = '/library/sections/%s/refresh?force=1' % self.key
        self._server.query(key)

    def deleteMediaPreviews(self):
        """ Delete the preview thumbnails for items in this library. This cannot
            be undone. Recreating media preview files can take hours or even days.
        """
        key = '/library/sections/%s/indexes' % self.key
        self._server.query(key, method=self._server._session.delete)

    def _loadFilters(self):
        """ 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/%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)
        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. """
        if self._filterTypes is None:
            self._loadFilters()
        return self._filterTypes

    def getFilterType(self, libtype=None):
        """ Returns a :class:`~plexapi.library.FilteringType` for a specified libtype.

            Parameters:
                libtype (str, optional): The library type to filter (movie, show, season, episode,
                    artist, album, track, photoalbum, photo, collection).

            Raises:
                :exc:`~plexapi.exceptions.NotFound`: Unknown libtype for this library.
        """
        libtype = libtype or self.TYPE
        try:
            return next(f for f in self.filterTypes() if f.type == libtype)
        except StopIteration:
            availableLibtypes = [f.type for f in self.filterTypes()]
            raise NotFound('Unknown libtype "%s" for this library. '
                           'Available libtypes: %s'
                           % (libtype, availableLibtypes)) from None

    def fieldTypes(self):
        """ Returns a list of available :class:`~plexapi.library.FilteringFieldType` for this library section. """
        if self._fieldTypes is None:
            self._loadFilters()
        return self._fieldTypes

    def getFieldType(self, fieldType):
        """ Returns a :class:`~plexapi.library.FilteringFieldType` for a specified fieldType.
        
            Parameters:
                fieldType (str): The data type for the field (tag, integer, string, boolean, date,
                    subtitleLanguage, audioLanguage, resolution).

            Raises:
                :exc:`~plexapi.exceptions.NotFound`: Unknown fieldType for this library.
        """
        try:
            return next(f for f in self.fieldTypes() if f.type == fieldType)
        except StopIteration:
            availableFieldTypes = [f.type for f in self.fieldTypes()]
            raise NotFound('Unknown field type "%s" for this library. '
                           'Available field types: %s'
                           % (fieldType, availableFieldTypes)) from None

    def listFilters(self, libtype=None):
        """ Returns a list of available :class:`~plexapi.library.FilteringFilter` for a specified libtype.
            This is the list of options in the filter dropdown menu
            (`screenshot <../_static/images/LibrarySection.listFilters.png>`__).

            Parameters:
                libtype (str, optional): The library type to filter (movie, show, season, episode,
                    artist, album, track, photoalbum, photo, collection).

            Example:

                .. code-block:: python

                    availableFilters = [f.filter for f in library.listFilters()]
                    print("Available filter fields:", availableFilters)

        """
        return self.getFilterType(libtype).filters
        
    def listSorts(self, libtype=None):
        """ Returns a list of available :class:`~plexapi.library.FilteringSort` for a specified libtype.
            This is the list of options in the sorting dropdown menu
            (`screenshot <../_static/images/LibrarySection.listSorts.png>`__).

            Parameters:
                libtype (str, optional): The library type to filter (movie, show, season, episode,
                    artist, album, track, photoalbum, photo, collection).

            Example:

                .. code-block:: python

                    availableSorts = [f.key for f in library.listSorts()]
                    print("Available sort fields:", availableSorts)

        """
        return self.getFilterType(libtype).sorts

    def listFields(self, libtype=None):
        """ Returns a list of available :class:`~plexapi.library.FilteringFields` for a specified libtype.
            This is the list of options in the custom filter dropdown menu
            (`screenshot <../_static/images/LibrarySection.search.png>`__).

            Parameters:
                libtype (str, optional): The library type to filter (movie, show, season, episode,
                    artist, album, track, photoalbum, photo, collection).

            Example:

                .. code-block:: python

                    availableFields = [f.key.split('.')[-1] for f in library.listFields()]
                    print("Available fields:", availableFields)

        """
        return self.getFilterType(libtype).fields

    def listOperators(self, fieldType):
        """ Returns a list of available :class:`~plexapi.library.FilteringOperator` for a specified fieldType.
            This is the list of options in the custom filter operator dropdown menu
            (`screenshot <../_static/images/LibrarySection.search.png>`__).
        
            Parameters:
                fieldType (str): The data type for the field (tag, integer, string, boolean, date,
                    subtitleLanguage, audioLanguage, resolution).

            Example:

                .. code-block:: python

                    field = 'genre'  # Available filter field from listFields()
                    filterField = next(f for f in library.listFields() if f.key.endswith(field))
                    availableOperators = [o.key for o in library.listOperators(filterField.type)]
                    print("Available operators for %s:" % field, availableOperators)

        """
        return self.getFieldType(fieldType).operators

    def listFilterChoices(self, field, libtype=None):
        """ Returns a list of available :class:`~plexapi.library.FilterChoice` for a specified
            :class:`~plexapi.library.FilteringFilter` or filter field.
            This is the list of available values for a custom filter
            (`screenshot <../_static/images/LibrarySection.search.png>`__).
            
            Parameters:
                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, collection).

            Raises:
                :exc:`~plexapi.exceptions.BadRequest`: Invalid filter field.
                :exc:`~plexapi.exceptions.NotFound`: Unknown filter field.

            Example:

                .. code-block:: python

                    field = 'genre'  # Available filter field from listFilters()
                    availableChoices = [f.title for f in library.listFilterChoices(field)]
                    print("Available choices for %s:" % field, availableChoices)

        """
        if isinstance(field, str):
            match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+)', field)
            if not match:
                raise BadRequest('Invalid filter field: %s' % field)
            _libtype, field = match.groups()
            libtype = _libtype or libtype or self.TYPE
            try:
                field = next(f for f in self.listFilters(libtype) if f.filter == field)
            except StopIteration:
                availableFilters = [f.filter for f in self.listFilters(libtype)]
                raise NotFound('Unknown filter field "%s" for libtype "%s". '
                               'Available filters: %s'
                               % (field, libtype, availableFilters)) from None
                
        data = self._server.query(field.key)
        return self.findItems(data, FilterChoice)

    def _validateFilterField(self, field, values, libtype=None):
        """ Validates a filter field and values are available as a custom filter for the library.
            Returns the validated field and values as a URL encoded parameter string.
        """
        match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+)([!<>=&]*)', field)
        if not match:
            raise BadRequest('Invalid filter field: %s' % field)
        _libtype, field, operator = match.groups()
        libtype = _libtype or libtype or self.TYPE

        try:
            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.split('.')[-1] == field), None)
                    if filterField:
                        break
            else:
                availableFields = [f.key for f in self.listFields(libtype)]
                raise NotFound('Unknown filter field "%s" for libtype "%s". '
                               'Available filter fields: %s'
                               % (field, libtype, availableFields)) from None

        field = filterField.key
        operator = self._validateFieldOperator(filterField, operator)
        result = self._validateFieldValue(filterField, values, libtype)

        if operator == '&=':
            args = {field: result}
            return urlencode(args, doseq=True)
        else:
            args = {field + operator[:-1]: ','.join(result)}
            return urlencode(args)

    def _validateFieldOperator(self, filterField, operator):
        """ Validates filter operator is in the available operators.
            Returns the validated operator string.
        """
        fieldType = self.getFieldType(filterField.type)

        and_operator = False
        if operator in {'&', '&='}:
            and_operator = True
            operator = ''
        if fieldType.type == 'string' and operator in {'=', '!='}:
            operator += '='
        operator = (operator[:-1] if operator[-1:] == '=' else operator) + '='

        try:
            next(o for o in fieldType.operators if o.key == operator)
        except StopIteration:
            availableOperators = [o.key for o in self.listOperators(filterField.type)]
            raise NotFound('Unknown operator "%s" for filter field "%s". '
                           'Available operators: %s'
                           % (operator, filterField.key, availableOperators)) from None

        return '&=' if and_operator else operator

    def _validateFieldValue(self, filterField, values, libtype=None):
        """ Validates filter values are the correct datatype and in the available filter choices.
            Returns the validated list of values.
        """
        if not isinstance(values, (list, tuple)):
            values = [values]

        fieldType = self.getFieldType(filterField.type)
        results = []

        try:
            for value in values:
                if fieldType.type == 'boolean':
                    value = int(bool(value))
                elif fieldType.type == 'date':
                    value = self._validateFieldValueDate(value)
                elif fieldType.type == 'integer':
                    value = float(value) if '.' in str(value) else int(value)
                elif fieldType.type == 'string':
                    value = str(value)
                elif fieldType.type in {'tag', 'subtitleLanguage', 'audioLanguage', 'resolution'}:
                    value = self._validateFieldValueTag(value, filterField, libtype)
                results.append(str(value))
        except (ValueError, AttributeError):
            raise BadRequest('Invalid value "%s" for filter field "%s", value should be type %s'
                             % (value, filterField.key, fieldType.type)) from None
    
        return results

    def _validateFieldValueDate(self, value):
        """ Validates a filter date value. A filter date value can be a datetime object,
            a relative date (e.g. -30d), or a date in YYYY-MM-DD format.
        """
        if isinstance(value, datetime):
            return int(value.timestamp())
        elif re.match(r'^-?\d+(mon|[smhdwy])$', value):
            return '-' + value.lstrip('-')
        else:
            return int(utils.toDatetime(value, '%Y-%m-%d').timestamp())

    def _validateFieldValueTag(self, value, filterField, libtype):
        """ Validates a filter tag value. A filter tag value can be a :class:`~plexapi.library.FilterChoice` object,
            a :class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*),
            or the exact id :attr:`MediaTag.id` (*int*).
        """
        if isinstance(value, FilterChoice):
            return value.key
        if isinstance(value, media.MediaTag):
            value = str(value.id or value.tag)
        else:
            value = str(value)
        filterChoices = self.listFilterChoices(filterField.key, libtype)
        matchValue = value.lower()
        return next((f.key for f in filterChoices if matchValue in {f.key.lower(), f.title.lower()}), value)

    def _validateSortFields(self, sort, libtype=None):
        """ Validates a list of filter sort fields is available for the library. Sort fields can be a
            list of :class:`~plexapi.library.FilteringSort` objects, or a comma separated string.
            Returns the validated comma separated sort fields string.
        """
        if isinstance(sort, str):
            sort = sort.split(',')

        if not isinstance(sort, (list, tuple)):
            sort = [sort]

        validatedSorts = []
        for _sort in sort:
            validatedSorts.append(self._validateSortField(_sort, libtype))

        return ','.join(validatedSorts)

    def _validateSortField(self, sort, libtype=None):
        """ Validates a filter sort field is available for the library. A sort field can be a
            :class:`~plexapi.library.FilteringSort` object, or a string.
            Returns the validated sort field string.
        """
        if isinstance(sort, FilteringSort):
            return '%s.%s:%s' % (libtype or self.TYPE, sort.key, sort.defaultDirection)

        match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+):?([a-zA-Z]*)', sort.strip())
        if not match:
            raise BadRequest('Invalid filter sort: %s' % sort)
        _libtype, sortField, sortDir = match.groups()
        libtype = _libtype or libtype or self.TYPE

        try:
            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 = libtype + '.' + filterSort.key

        availableDirections = ['', 'asc', 'desc', 'nullsLast']
        if sortDir not in availableDirections:
            raise NotFound('Unknown sort direction "%s". '
                           'Available sort directions: %s'
                           % (sortDir, availableDirections))

        return '%s:%s' % (sortField, sortDir) if sortDir else sortField

    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 = []

        args['includeGuids'] = int(bool(kwargs.pop('includeGuids', True)))
        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, 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 (:class:`~plexapi.library.FilteringSort` or str or list, optional): A field to sort the results.
                    See the details below for more info.
                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, 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.

            Raises:
                :exc:`~plexapi.exceptions.BadRequest`: When the sort or filter is invalid.
                :exc:`~plexapi.exceptions.NotFound`: When applying an unknown sort or filter.

            **Sorting Results**

            The search results can be sorted by including the ``sort`` parameter.

            * See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields.

            The ``sort`` parameter can be a :class:`~plexapi.library.FilteringSort` object or a sort string in the
            format ``field:dir``. The sort direction ``dir`` can be ``asc``, ``desc``, or ``nullsLast``. Omitting the
            sort direction or using a :class:`~plexapi.library.FilteringSort` object will sort the results in the default
            direction of the field. Multi-sorting on multiple fields can be achieved by using a comma separated list of
            sort strings, or a list of :class:`~plexapi.library.FilteringSort` object or strings.

            Examples:

                .. code-block:: python

                    library.search(sort="titleSort:desc")  # Sort title in descending order
                    library.search(sort="titleSort")  # Sort title in the default order
                    # Multi-sort by year in descending order, then by audience rating in descending order
                    library.search(sort="year:desc,audienceRating:desc")
                    library.search(sort=["year:desc", "audienceRating:desc"])

            **Using Plex Filters**

            Any of the available custom filters can be applied to the search results
            (`screenshot <../_static/images/LibrarySection.search.png>`__).

            * See :func:`~plexapi.library.LibrarySection.listFields` to get a list of all available fields.
            * See :func:`~plexapi.library.LibrarySection.listOperators` to get a list of all available operators.
            * See :func:`~plexapi.library.LibrarySection.listFilterChoices` to get a list of all available filter values.

            The following filter fields are just some examples of the possible filters. The list is not exaustive,
            and not all filters apply to all library types.

            * **actor** (:class:`~plexapi.media.MediaTag`): Search for the name of an actor.
            * **addedAt** (*datetime*): Search for items added before or after a date. See operators below.
            * **audioLanguage** (*str*): Search for a specific audio language (3 character code, e.g. jpn).
            * **collection** (:class:`~plexapi.media.MediaTag`): Search for the name of a collection.
            * **contentRating** (:class:`~plexapi.media.MediaTag`): Search for a specific content rating.
            * **country** (:class:`~plexapi.media.MediaTag`): Search for the name of a country.
            * **decade** (*int*): Search for a specific decade (e.g. 2000).
            * **director** (:class:`~plexapi.media.MediaTag`): Search for the name of a director.
            * **duplicate** (*bool*) Search for duplicate items.
            * **genre** (:class:`~plexapi.media.MediaTag`): Search for a specific genre.
            * **hdr** (*bool*): Search for HDR items.
            * **inProgress** (*bool*): Search for in progress items.
            * **label** (:class:`~plexapi.media.MediaTag`): Search for a specific label.
            * **lastViewedAt** (*datetime*): Search for items watched before or after a date. See operators below.
            * **mood** (:class:`~plexapi.media.MediaTag`): Search for a specific mood.
            * **producer** (:class:`~plexapi.media.MediaTag`): Search for the name of a producer.
            * **resolution** (*str*): Search for a specific resolution (e.g. 1080).
            * **studio** (*str*): Search for the name of a studio.
            * **style** (:class:`~plexapi.media.MediaTag`): Search for a specific style.
            * **subtitleLanguage** (*str*): Search for a specific subtitle language (3 character code, e.g. eng)
            * **unmatched** (*bool*): Search for unmatched items.
            * **unwatched** (*bool*): Search for unwatched items.
            * **userRating** (*int*): Search for items with a specific user rating.
            * **writer** (:class:`~plexapi.media.MediaTag`): Search for the name of a writer.
            * **year** (*int*): Search for a specific year.

            Tag type filter values can be a :class:`~plexapi.library.FilterChoice` object,
            :class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*),
            or the exact id :attr:`MediaTag.id` (*int*).
            
            Date type filter values can be a ``datetime`` object, a relative date using a one of the
            available date suffixes (e.g. ``30d``) (*str*), or a date in ``YYYY-MM-DD`` (*str*) format.

            Relative date suffixes:

            * ``s``: ``seconds``
            * ``m``: ``minutes``
            * ``h``: ``hours``
            * ``d``: ``days``
            * ``w``: ``weeks``
            * ``mon``: ``months``
            * ``y``: ``years``
            
            Multiple values can be ``OR`` together by providing a list of values.

            Examples:

                .. code-block:: python

                    library.search(unwatched=True, year=2020, resolution="4k")
                    library.search(actor="Arnold Schwarzenegger", decade=1990)
                    library.search(contentRating="TV-G", genre="animation")
                    library.search(genre=["animation", "comedy"])  # Genre is animation OR comedy
                    library.search(studio=["Disney", "Pixar"])  # Studio contains Disney OR Pixar

            **Using a** ``libtype`` **Prefix**

            Some filters may be prefixed by the ``libtype`` separated by a ``.`` (e.g. ``show.collection``,
            ``episode.title``, ``artist.style``, ``album.genre``, ``track.userRating``, etc.). This should not be
            confused with the ``libtype`` parameter. If no ``libtype`` prefix is provided, then the default library
            type is assumed. For example, in a TV show library ``viewCout`` is assumed to be ``show.viewCount``.
            If you want to filter using episode view count then you must specify ``episode.viewCount`` explicitly.
            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 filters must be provided as a filters dictionary.

            Examples:

                .. code-block:: python

                    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', 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(filters={"episode.title": "Winter is Coming"})

                    # The following will fallback to episode.unwatched
                    showLibrary.search(unwatched=True)

            **Using Plex Operators**

            Operators can be appended to the filter field to narrow down results with more granularity. If no
            operator is specified, the default operator is assumed to be ``=``. The following is a list of
            possible operators depending on the data type of the filter being applied. A special ``&`` operator
            can also be used to ``AND`` together a list of values.

            Type: :class:`~plexapi.media.MediaTag` or *subtitleLanguage* or *audioLanguage*

            * ``=``: ``is``
            * ``!=``: ``is not``

            Type: *int*

            * ``=``: ``is``
            * ``!=``: ``is not``
            * ``>>=``: ``is greater than``
            * ``<<=``: ``is less than``

            Type: *str*

            * ``=``: ``contains``
            * ``!=``: ``does not contain``
            * ``==``: ``is``
            * ``!==``: ``is not``
            * ``<=``: ``begins with``
            * ``>=``: ``ends with``

            Type: *bool*

            * ``=``: ``is true``
            * ``!=``: ``is false``

            Type: *datetime*

            * ``<<=``: ``is before``
            * ``>>=``: ``is after``

            Type: *resolution*

            * ``=``: ``is``

            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(filters={"genre&": ["horror", "thriller"]})

                    # Director is not Steven Spielberg
                    library.search(filters={"director!": "Steven Spielberg"})

                    # Title starts with Marvel and added before 2021-01-01
                    library.search(filters={"title<": "Marvel", "addedAt<<": "2021-01-01"})

                    # Added in the last 30 days using relative dates
                    library.search(filters={"addedAt>>": "30d"})

                    # Collection is James Bond and user rating is greater than 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**

            For even more advanced filtering which cannot be achieved in Plex, the PlexAPI operators can be applied
            to any XML attribute. See :func:`plexapi.base.PlexObject.fetchItems` for a list of operators and how they
            are used. Note that using the Plex filters above will be faster since the filters are applied by the Plex
            server before the results are returned to PlexAPI. Using the PlexAPI operators requires the Plex server
            to return *all* results to allow PlexAPI to do the filtering. The Plex filters and the PlexAPI operators
            can be used in conjunction with each other.

            Examples:

                .. code-block:: python

                    library.search(summary__icontains="Christmas")
                    library.search(duration__gt=7200000)
                    library.search(audienceRating__lte=6.0, audienceRatingImage__startswith="rottentomatoes://")
                    library.search(media__videoCodec__exact="h265")
                    library.search(genre="holiday", viewCount__gte=3)

        """
        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):
        """ Perform the actual library search and return the results. """
        results = []
        subresults = []
        offset = container_start

        if maxresults is not None:
            container_size = min(container_size, maxresults)

        while True:
            subresults = self.fetchItems(key, container_start=container_start,
                                         container_size=container_size, **kwargs)
            if not len(subresults):
                if offset > self._totalViewSize:
                    log.info("container_start is higher than the number of items in the library")

            results.extend(subresults)

            # self._totalViewSize is not used as a condition in the while loop as
            # this require a additional http request.
            # self._totalViewSize is updated from self.fetchItems
            wanted_number_of_items = self._totalViewSize - offset
            if maxresults is not None:
                wanted_number_of_items = min(maxresults, wanted_number_of_items)
                container_size = min(container_size, maxresults - len(results))

            if wanted_number_of_items <= len(results):
                break

            container_start += container_size

            if container_start > self._totalViewSize:
                break

        return results

    def _locations(self):
        """ Returns a list of :class:`~plexapi.library.Location` objects
        """
        return self.findItems(self._data, Location)

    def sync(self, policy, mediaSettings, client=None, clientId=None, title=None, sort=None, libtype=None,
             **kwargs):
        """ Add current library section as sync item for specified device.
            See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting
            and :func:`~plexapi.myplex.MyPlexAccount.sync` for possible exceptions.

            Parameters:
                policy (:class:`~plexapi.sync.Policy`): policy of syncing the media (how many items to sync and process
                                                       watched media or not), generated automatically when method
                                                       called on specific LibrarySection object.
                mediaSettings (:class:`~plexapi.sync.MediaSettings`): Transcoding settings used for the media, generated
                                                                     automatically when method called on specific
                                                                     LibrarySection object.
                client (:class:`~plexapi.myplex.MyPlexDevice`): sync destination, see
                                                               :func:`~plexapi.myplex.MyPlexAccount.sync`.
                clientId (str): sync destination, see :func:`~plexapi.myplex.MyPlexAccount.sync`.
                title (str): descriptive title for the new :class:`~plexapi.sync.SyncItem`, if empty the value would be
                             generated from metadata of current media.
                sort (str): formatted as `column:dir`; column can be any of {`addedAt`, `originallyAvailableAt`,
                            `lastViewedAt`, `titleSort`, `rating`, `mediaHeight`, `duration`}. dir can be `asc` or
                            `desc`.
                libtype (str): Filter results to a specific libtype (`movie`, `show`, `episode`, `artist`, `album`,
                               `track`).

            Returns:
                :class:`~plexapi.sync.SyncItem`: an instance of created syncItem.

            Raises:
                :exc:`~plexapi.exceptions.BadRequest`: When the library is not allowed to sync.
                :exc:`~plexapi.exceptions.BadRequest`: When the sort or filter is invalid.
                :exc:`~plexapi.exceptions.NotFound`: When applying an unknown sort or filter.

            Example:

                .. code-block:: python

                    from plexapi import myplex
                    from plexapi.sync import Policy, MediaSettings, VIDEO_QUALITY_3_MBPS_720p

                    c = myplex.MyPlexAccount()
                    target = c.device('Plex Client')
                    sync_items_wd = c.syncItems(target.clientIdentifier)
                    srv = c.resource('Server Name').connect()
                    section = srv.library.section('Movies')
                    policy = Policy('count', unwatched=True, value=1)
                    media_settings = MediaSettings.create(VIDEO_QUALITY_3_MBPS_720p)
                    section.sync(target, policy, media_settings, title='Next best movie', sort='rating:desc')

        """
        from plexapi.sync import SyncItem

        if not self.allowSync:
            raise BadRequest('The requested library is not allowed to sync')

        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.CONTENT_TYPE
        sync_item.metadataType = self.METADATA_TYPE
        sync_item.machineIdentifier = self._server.machineIdentifier

        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
        sync_item.mediaSettings = mediaSettings

        return myplex.sync(client=client, clientId=clientId, sync_item=sync_item)

    def history(self, maxresults=9999999, mindate=None):
        """ Get Play History for this library Section for the owner.
            Parameters:
                maxresults (int): Only return the specified number of results (optional).
                mindate (datetime): Min datetime to return results from.
        """
        return self._server.history(maxresults=maxresults, mindate=mindate, librarySectionID=self.key, accountID=1)

    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.

            Raises:
                :exc:`~plexapi.exceptions.NotFound`: Unable to find collection.
        """
        try:
            return self.collections(title=title, title__iexact=title)[0]
        except IndexError:
            raise NotFound('Unable to find collection with title "%s".' % title) from None

    def collections(self, **kwargs):
        """ Returns a list of collections from this library section.
            See description of :func:`~plexapi.library.LibrarySection.search` for details about filtering / sorting.
        """
        return self.search(libtype='collection', **kwargs)

    def 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.

            Raises:
                :exc:`~plexapi.exceptions.NotFound`: Unable to find playlist.
        """
        try:
            return self.playlists(title=title, title__iexact=title)[0]
        except IndexError:
            raise NotFound('Unable to find playlist with title "%s".' % title) from None

    def playlists(self, sort=None, **kwargs):
        """ Returns a list of playlists from this library section. """
        return self._server.playlists(
            playlistType=self.CONTENT_TYPE, sectionId=self.key, sort=sort, **kwargs)

    @deprecated('use "listFields" instead')
    def filterFields(self, mediaType=None):
        return self.listFields(libtype=mediaType)

    @deprecated('use "listFilterChoices" instead')
    def listChoices(self, category, libtype=None, **kwargs):
        return self.listFilterChoices(field=category, libtype=libtype)

    def getWebURL(self, base=None, tab=None, key=None):
        """ Returns the Plex Web URL for the library.

            Parameters:
                base (str): The base URL before the fragment (``#!``).
                    Default is https://app.plex.tv/desktop.
                tab (str): The library tab (recommended, library, collections, playlists, timeline).
                key (str): A hub key.
        """
        params = {'source': self.key}
        if tab is not None:
            params['pivot'] = tab
        if key is not None:
            params['key'] = key
            params['pageType'] = 'list'
        return self._server._buildWebURL(base=base, **params)
plexapi/mixins.py
class SmartFilterMixin(object):
    """ Mixing for Plex objects that can have smart filters. """

    def _parseFilters(self, content):
        """ Parse the content string and returns the filter dict. """
        content = urlsplit(unquote(content))
        filters = {}
        filterOp = 'and'
        filterGroups = [[]]
        
        for key, value in parse_qsl(content.query):
            # Move = sign to key when operator is ==
            if value.startswith('='):
                key += '='
                value = value[1:]

            if key == 'includeGuids':
                filters['includeGuids'] = int(value)
            elif key == 'type':
                filters['libtype'] = utils.reverseSearchType(value)
            elif key == 'sort':
                filters['sort'] = value.split(',')
            elif key == 'limit':
                filters['limit'] = int(value)
            elif key == 'push':
                filterGroups[-1].append([])
                filterGroups.append(filterGroups[-1][-1])
            elif key == 'and':
                filterOp = 'and'
            elif key == 'or':
                filterOp = 'or'
            elif key == 'pop':
                filterGroups[-1].insert(0, filterOp)
                filterGroups.pop()
            else:
                filterGroups[-1].append({key: value})
        
        if filterGroups:
            filters['filters'] = self._formatFilterGroups(filterGroups.pop())
        return filters
    
    def _formatFilterGroups(self, groups):
        """ Formats the filter groups into the advanced search rules. """
        if len(groups) == 1 and isinstance(groups[0], list):
            groups = groups.pop()

        filterOp = 'and'
        rules = []

        for g in groups:
            if isinstance(g, list):
                rules.append(self._formatFilterGroups(g))
            elif isinstance(g, dict):
                rules.append(g)
            elif g in {'and', 'or'}:
                filterOp = g

        return {filterOp: rules}
plexapi/utils.py
SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7,
               'artist': 8, 'album': 9, 'track': 10, 'picture': 11, 'clip': 12, 'photo': 13, 'photoalbum': 14,
               'playlist': 15, 'playlistFolder': 16, 'collection': 18, 'optimizedVersion': 42, 'userPlaylistItem': 1001}
PLEXOBJECTS = {}
class SecretsFilter(logging.Filter):
    def __init__(self, secrets=None):
    def add_secret(self, secret):
    def filter(self, record):
def registerPlexObject(cls):
def cast(func, value):
def joinArgs(args):
def lowerFirst(s):
def rget(obj, attrstr, default=None, delim='.'):  # pragma: no cover
def searchType(libtype):
def reverseSearchType(libtype):
def threaded(callback, listargs):
def toDatetime(value, format=None):
def millisecondToHumanstr(milliseconds):
def toList(value, itemcast=None, delim=','):
def cleanFilename(filename, replace='_'):
def downloadSessionImages(server, filename=None, height=150, width=150,
                          opacity=100, saturation=100):  # pragma: no cover
def download(url, token, filename=None, savepath=None, session=None, chunksize=4024,
             unpack=False, mocked=False, showstatus=False):
def tag_singular(tag):
def tag_plural(tag):
def tag_helper(tag, items, locked=True, remove=False):
def getMyPlexAccount(opts=None):  # pragma: no cover
def createMyPlexDevice(headers, account, timeout=10):  # pragma: no cover
def choose(msg, items, attr):  # pragma: no cover
def getAgentIdentifier(section, agent):
def base64str(text):
def deprecated(message, stacklevel=2):
    def decorator(func):
        def wrapper(*args, **kwargs):
plexapi/mixins.py
class ArtMixin(ArtUrlMixin):
    """ Mixin for Plex objects that can have background artwork. """

    def arts(self):
        """ Returns list of available :class:`~plexapi.media.Art` objects. """
        return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey, cls=media.Art)

    def uploadArt(self, url=None, filepath=None):
        """ Upload a background artwork from a url or filepath.
        
            Parameters:
                url (str): The full URL to the image to upload.
                filepath (str): The full file path the the image to upload.
        """
        if url:
            key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url))
            self._server.query(key, method=self._server._session.post)
        elif filepath:
            key = '/library/metadata/%s/arts?' % self.ratingKey
            data = open(filepath, 'rb').read()
            self._server.query(key, method=self._server._session.post, data=data)

    def setArt(self, art):
        """ Set the background artwork for a Plex object.
        
            Parameters:
                art (:class:`~plexapi.media.Art`): The art object to select.
        """
        art.select()

    def lockArt(self):
        """ Lock the background artwork for a Plex object. """
        self._edit(**{'art.locked': 1})

    def unlockArt(self):
        """ Unlock the background artwork for a Plex object. """
        self._edit(**{'art.locked': 0})
plexapi/exceptions.py
class NotFound(PlexApiException):
    """ Request media item or device is not found. """
    pass
plexapi/exceptions.py
class Unsupported(PlexApiException):
    """ Unsupported client request. """
    pass
plexapi/exceptions.py
class BadRequest(PlexApiException):
    """ An invalid request, generally a user error. """
    pass
plexapi/utils.py
def deprecated(message, stacklevel=2):
    def decorator(func):
        """This is a decorator which can be used to mark functions
        as deprecated. It will result in a warning being emitted
        when the function is used."""
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            msg = 'Call to deprecated function or method "%s", %s.' % (func.__name__, message)
            warnings.warn(msg, category=DeprecationWarning, stacklevel=stacklevel)
            log.warning(msg)
            return func(*args, **kwargs)
        return wrapper
    return decorator
plexapi/base.py
class Playable(object):
    """ This is a general place to store functions specific to media that is Playable.
        Things were getting mixed up a bit when dealing with Shows, Season, Artists,
        Albums which are all not playable.

        Attributes:
            sessionKey (int): Active session key.
            usernames (str): Username of the person playing this item (for active sessions).
            players (:class:`~plexapi.client.PlexClient`): Client objects playing this item (for active sessions).
            session (:class:`~plexapi.media.Session`): Session object, for a playing media file.
            transcodeSessions (:class:`~plexapi.media.TranscodeSession`): Transcode Session object
                if item is being transcoded (None otherwise).
            viewedAt (datetime): Datetime item was last viewed (history).
            accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
            deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID.
            playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
            playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items).
    """

    def _loadData(self, data):
        self.sessionKey = utils.cast(int, data.attrib.get('sessionKey'))            # session
        self.usernames = self.listAttrs(data, 'title', etag='User')                 # session
        self.players = self.findItems(data, etag='Player')                          # session
        self.transcodeSessions = self.findItems(data, etag='TranscodeSession')      # session
        self.session = self.findItems(data, etag='Session')                         # session
        self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt'))               # history
        self.accountID = utils.cast(int, data.attrib.get('accountID'))              # history
        self.deviceID = utils.cast(int, data.attrib.get('deviceID'))                # history
        self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID'))    # playlist
        self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID'))  # playqueue

    def getStreamURL(self, **params):
        """ Returns a stream url that may be used by external applications such as VLC.

            Parameters:
                **params (dict): optional parameters to manipulate the playback when accessing
                    the stream. A few known parameters include: maxVideoBitrate, videoResolution
                    offset, copyts, protocol, mediaIndex, platform.

            Raises:
                :exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL.
        """
        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', '')
        params = {
            'path': self.key,
            'offset': params.get('offset', 0),
            'copyts': params.get('copyts', 1),
            'protocol': params.get('protocol'),
            'mediaIndex': params.get('mediaIndex', 0),
            'X-Plex-Platform': params.get('platform', 'Chrome'),
            'maxVideoBitrate': max(mvb, 64) if mvb else None,
            'videoResolution': vr if re.match(r'^\d+x\d+$', vr) else None
        }
        # remove None values
        params = {k: v for k, v in params.items() if v is not None}
        streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video'
        # sort the keys since the randomness fucks with my tests..
        sorted_params = sorted(params.items(), key=lambda val: val[0])
        return self._server.url('/%s/:/transcode/universal/start.m3u8?%s' %
            (streamtype, urlencode(sorted_params)), includeToken=True)

    def iterParts(self):
        """ Iterates over the parts of this media item. """
        for item in self.media:
            for part in item.parts:
                yield part

    def play(self, client):
        """ Start playback on the specified client.

            Parameters:
                client (:class:`~plexapi.client.PlexClient`): Client to start playing on.
        """
        client.playMedia(self)

    def download(self, savepath=None, keep_original_name=False, **kwargs):
        """ Downloads the media item to the specified location. Returns a list of
            filepaths that have been saved to disk.

            Parameters:
                savepath (str): Defaults to current working dir.
                keep_original_name (bool): True to keep the original filename otherwise
                    a friendlier filename is generated. See filenames below.
                **kwargs (dict): Additional options passed into :func:`~plexapi.audio.Track.getStreamURL`
                    to download a transcoded stream, otherwise the media item will be downloaded
                    as-is and saved to disk.

            **Filenames**

            * Movie: ``<title> (<year>)``
            * Episode: ``<show title> - s00e00 - <episode title>``
            * Track: ``<artist title> - <album title> - 00 - <track title>``
            * Photo: ``<photoalbum title> - <photo/clip title>`` or ``<photo/clip title>``
        """
        filepaths = []
        parts = [i for i in self.iterParts() if i]

        for part in parts:
            if not keep_original_name:
                filename = utils.cleanFilename('%s.%s' % (self._prettyfilename(), part.container))
            else:
                filename = part.file

            if kwargs:
                # So this seems to be a alot slower but allows transcode.
                download_url = self.getStreamURL(**kwargs)
            else:
                download_url = self._server.url('%s?download=1' % part.key)

            filepath = utils.download(
                download_url,
                self._server._token,
                filename=filename,
                savepath=savepath,
                session=self._server._session
            )

            if filepath:
                filepaths.append(filepath)

        return filepaths

    def stop(self, reason=''):
        """ Stop playback for a media item. """
        key = '/status/sessions/terminate?sessionId=%s&reason=%s' % (self.session[0].id, quote_plus(reason))
        return self._server.query(key)

    def updateProgress(self, time, state='stopped'):
        """ Set the watched progress for this video.

        Note that setting the time to 0 will not work.
        Use `markWatched` or `markUnwatched` to achieve
        that goal.

            Parameters:
                time (int): milliseconds watched
                state (string): state of the video, default 'stopped'
        """
        key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey,
                                                                                              time, state)
        self._server.query(key)
        self._reload(_autoReload=True)

    def updateTimeline(self, time, state='stopped', duration=None):
        """ Set the timeline progress for this video.

            Parameters:
                time (int): milliseconds watched
                state (string): state of the video, default 'stopped'
                duration (int): duration of the item
        """
        durationStr = '&duration='
        if duration is not None:
            durationStr = durationStr + str(duration)
        else:
            durationStr = durationStr + str(self.duration)
        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(_autoReload=True)
plexapi/playqueue.py
class PlayQueue(PlexObject):
    """Control a PlayQueue.

    Attributes:
        TAG (str): 'PlayQueue'
        TYPE (str): 'playqueue'
        identifier (str): com.plexapp.plugins.library
        items (list): List of :class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`
        mediaTagPrefix (str): Fx /system/bundle/media/flags/
        mediaTagVersion (int): Fx 1485957738
        playQueueID (int): ID of the PlayQueue.
        playQueueLastAddedItemID (int):
            Defines where the "Up Next" region starts. Empty unless PlayQueue is modified after creation.
        playQueueSelectedItemID (int): The queue item ID of the currently selected item.
        playQueueSelectedItemOffset (int):
            The offset of the selected item in the PlayQueue, from the beginning of the queue.
        playQueueSelectedMetadataItemID (int): ID of the currently selected item, matches ratingKey.
        playQueueShuffled (bool): True if shuffled.
        playQueueSourceURI (str): Original URI used to create the PlayQueue.
        playQueueTotalCount (int): How many items in the PlayQueue.
        playQueueVersion (int): Version of the PlayQueue. Increments every time a change is made to the PlayQueue.
        selectedItem (:class:`~plexapi.media.Media`): Media object for the currently selected item.
        _server (:class:`~plexapi.server.PlexServer`): PlexServer associated with the PlayQueue.
        size (int): Alias for playQueueTotalCount.
    """

    TAG = "PlayQueue"
    TYPE = "playqueue"

    def _loadData(self, data):
        self._data = data
        self.identifier = data.attrib.get("identifier")
        self.mediaTagPrefix = data.attrib.get("mediaTagPrefix")
        self.mediaTagVersion = utils.cast(int, data.attrib.get("mediaTagVersion"))
        self.playQueueID = utils.cast(int, data.attrib.get("playQueueID"))
        self.playQueueLastAddedItemID = utils.cast(
            int, data.attrib.get("playQueueLastAddedItemID")
        )
        self.playQueueSelectedItemID = utils.cast(
            int, data.attrib.get("playQueueSelectedItemID")
        )
        self.playQueueSelectedItemOffset = utils.cast(
            int, data.attrib.get("playQueueSelectedItemOffset")
        )
        self.playQueueSelectedMetadataItemID = utils.cast(
            int, data.attrib.get("playQueueSelectedMetadataItemID")
        )
        self.playQueueShuffled = utils.cast(
            bool, data.attrib.get("playQueueShuffled", 0)
        )
        self.playQueueSourceURI = data.attrib.get("playQueueSourceURI")
        self.playQueueTotalCount = utils.cast(
            int, data.attrib.get("playQueueTotalCount")
        )
        self.playQueueVersion = utils.cast(int, data.attrib.get("playQueueVersion"))
        self.size = utils.cast(int, data.attrib.get("size", 0))
        self.items = self.findItems(data)
        self.selectedItem = self[self.playQueueSelectedItemOffset]

    def __getitem__(self, key):
        if not self.items:
            return None
        return self.items[key]

    def __len__(self):
        return self.playQueueTotalCount

    def __iter__(self):
        yield from self.items

    def __contains__(self, media):
        """Returns True if the PlayQueue contains the provided media item."""
        return any(x.playQueueItemID == media.playQueueItemID for x in self.items)

    def getQueueItem(self, item):
        """
        Accepts a media item and returns a similar object from this PlayQueue.
        Useful for looking up playQueueItemIDs using items obtained from the Library.
        """
        matches = [x for x in self.items if x == item]
        if len(matches) == 1:
            return matches[0]
        elif len(matches) > 1:
            raise BadRequest(
                "{item} occurs multiple times in this PlayQueue, provide exact item".format(item=item)
            )
        else:
            raise BadRequest("{item} not valid for this PlayQueue".format(item=item))

    @classmethod
    def get(
        cls,
        server,
        playQueueID,
        own=False,
        center=None,
        window=50,
        includeBefore=True,
        includeAfter=True,
    ):
        """Retrieve an existing :class:`~plexapi.playqueue.PlayQueue` by identifier.

        Parameters:
            server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
            playQueueID (int): Identifier of an existing PlayQueue.
            own (bool, optional): If server should transfer ownership.
            center (int, optional): The playQueueItemID of the center of the window. Does not change selectedItem.
            window (int, optional): Number of items to return from each side of the center item.
            includeBefore (bool, optional):
                Include items before the center, defaults True. Does not include center if False.
            includeAfter (bool, optional):
                Include items after the center, defaults True. Does not include center if False.
        """
        args = {
            "own": utils.cast(int, own),
            "window": window,
            "includeBefore": utils.cast(int, includeBefore),
            "includeAfter": utils.cast(int, includeAfter),
        }
        if center:
            args["center"] = center

        path = "/playQueues/{playQueueID}{args}".format(playQueueID=playQueueID, args=utils.joinArgs(args))
        data = server.query(path, method=server._session.get)
        c = cls(server, data, initpath=path)
        c._server = server
        return c

    @classmethod
    def create(
        cls,
        server,
        items,
        startItem=None,
        shuffle=0,
        repeat=0,
        includeChapters=1,
        includeRelated=1,
        continuous=0,
    ):
        """Create and return a new :class:`~plexapi.playqueue.PlayQueue`.

        Parameters:
            server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
            items (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`):
                A media item, list of media items, or Playlist.
            startItem (:class:`~plexapi.media.Media`, optional):
                Media item in the PlayQueue where playback should begin.
            shuffle (int, optional): Start the playqueue shuffled.
            repeat (int, optional): Start the playqueue shuffled.
            includeChapters (int, optional): include Chapters.
            includeRelated (int, optional): include Related.
            continuous (int, optional): include additional items after the initial item.
                For a show this would be the next episodes, for a movie it does nothing.
        """
        args = {
            "includeChapters": includeChapters,
            "includeRelated": includeRelated,
            "repeat": repeat,
            "shuffle": shuffle,
            "continuous": continuous,
        }

        if isinstance(items, list):
            item_keys = ",".join([str(x.ratingKey) for x in items])
            uri_args = quote_plus("/library/metadata/{item_keys}".format(item_keys=item_keys))
            args["uri"] = "library:///directory/{uri_args}".format(uri_args=uri_args)
            args["type"] = items[0].listType
        elif items.type == "playlist":
            args["type"] = items.playlistType
            if items.radio:
                args["uri"] = f"server://{server.machineIdentifier}/{server.library.identifier}{items.key}"
            else:
                args["playlistID"] = items.ratingKey
        else:
            uuid = items.section().uuid
            args["type"] = items.listType
            args["uri"] = "library://{uuid}/item/{key}".format(uuid=uuid, key=items.key)

        if startItem:
            args["key"] = startItem.key

        path = "/playQueues{args}".format(args=utils.joinArgs(args))
        data = server.query(path, method=server._session.post)
        c = cls(server, data, initpath=path)
        c.playQueueType = args["type"]
        c._server = server
        return c

    @classmethod
    def fromStationKey(cls, server, key):
        """Create and return a new :class:`~plexapi.playqueue.PlayQueue`.

        This is a convenience method to create a `PlayQueue` for
        radio stations when only the `key` string is available.

        Parameters:
            server (:class:`~plexapi.server.PlexServer`): Server you are connected to.
            key (str): A station key as provided by :func:`~plexapi.library.LibrarySection.hubs()`
                or :func:`~plexapi.audio.Artist.station()`

        Example:

            .. code-block:: python

                from plexapi.playqueue import PlayQueue
                music = server.library.section("Music")
                artist = music.get("Artist Name")
                station = artist.station()
                key = station.key  # "/library/metadata/12855/station/8bd39616-dbdb-459e-b8da-f46d0b170af4?type=10"
                pq = PlayQueue.fromStationKey(server, key)
                client = server.clients()[0]
                client.playMedia(pq)
        """
        args = {
            "type": "audio",
            "uri": f"server://{server.machineIdentifier}/{server.library.identifier}{key}"
        }
        path = f"/playQueues{utils.joinArgs(args)}"
        data = server.query(path, method=server._session.post)
        c = cls(server, data, initpath=path)
        c.playQueueType = args["type"]
        c._server = server
        return c

    def addItem(self, item, playNext=False, refresh=True):
        """
        Append the provided item to the "Up Next" section of the PlayQueue.
        Items can only be added to the section immediately following the current playing item.

        Parameters:
            item (:class:`~plexapi.media.Media` or :class:`~plexapi.playlist.Playlist`): Single media item or Playlist.
            playNext (bool, optional): If True, add this item to the front of the "Up Next" section.
                If False, the item will be appended to the end of the "Up Next" section.
                Only has an effect if an item has already been added to the "Up Next" section.
                See https://support.plex.tv/articles/202188298-play-queues/ for more details.
            refresh (bool, optional): Refresh the PlayQueue from the server before updating.
        """
        if refresh:
            self.refresh()

        args = {}
        if item.type == "playlist":
            args["playlistID"] = item.ratingKey
            itemType = item.playlistType
        else:
            uuid = item.section().uuid
            itemType = item.listType
            args["uri"] = "library://{uuid}/item{key}".format(uuid=uuid, key=item.key)

        if itemType != self.playQueueType:
            raise Unsupported("Item type does not match PlayQueue type")

        if playNext:
            args["next"] = 1

        path = "/playQueues/{playQueueID}{args}".format(playQueueID=self.playQueueID, args=utils.joinArgs(args))
        data = self._server.query(path, method=self._server._session.put)
        self._loadData(data)

    def moveItem(self, item, after=None, refresh=True):
        """
        Moves an item to the beginning of the PlayQueue.  If `after` is provided,
        the item will be placed immediately after the specified item.

        Parameters:
            item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move.
            afterItemID (:class:`~plexapi.base.Playable`, optional): A different item in the PlayQueue.
                If provided, `item` will be placed in the PlayQueue after this item.
            refresh (bool, optional): Refresh the PlayQueue from the server before updating.
        """
        args = {}

        if refresh:
            self.refresh()

        if item not in self:
            item = self.getQueueItem(item)

        if after:
            if after not in self:
                after = self.getQueueItem(after)
            args["after"] = after.playQueueItemID

        path = "/playQueues/{playQueueID}/items/{playQueueItemID}/move{args}".format(
            playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID, args=utils.joinArgs(args)
        )
        data = self._server.query(path, method=self._server._session.put)
        self._loadData(data)

    def removeItem(self, item, refresh=True):
        """Remove an item from the PlayQueue.

        Parameters:
            item (:class:`~plexapi.base.Playable`): An existing item in the PlayQueue to move.
            refresh (bool, optional): Refresh the PlayQueue from the server before updating.
        """
        if refresh:
            self.refresh()

        if item not in self:
            item = self.getQueueItem(item)

        path = "/playQueues/{playQueueID}/items/{playQueueItemID}".format(
            playQueueID=self.playQueueID, playQueueItemID=item.playQueueItemID
        )
        data = self._server.query(path, method=self._server._session.delete)
        self._loadData(data)

    def clear(self):
        """Remove all items from the PlayQueue."""
        path = "/playQueues/{playQueueID}/items".format(playQueueID=self.playQueueID)
        data = self._server.query(path, method=self._server._session.delete)
        self._loadData(data)

    def refresh(self):
        """Refresh the PlayQueue from the Plex server."""
        path = "/playQueues/{playQueueID}".format(playQueueID=self.playQueueID)
        data = self._server.query(path, method=self._server._session.get)
        self._loadData(data)
plexapi/media.py
class Media(PlexObject):
class MediaPart(PlexObject):
class MediaPartStream(PlexObject):
class VideoStream(MediaPartStream):
class AudioStream(MediaPartStream):
class SubtitleStream(MediaPartStream):
class LyricStream(MediaPartStream):
class Session(PlexObject):
class TranscodeSession(PlexObject):
class TranscodeJob(PlexObject):
class Optimized(PlexObject):
class Conversion(PlexObject):
class MediaTag(PlexObject):
class Collection(MediaTag):
class Country(MediaTag):
class Director(MediaTag):
class Format(MediaTag):
class Genre(MediaTag):
class Label(MediaTag):
class Mood(MediaTag):
class Producer(MediaTag):
class Role(MediaTag):
class Similar(MediaTag):
class Style(MediaTag):
class Subformat(MediaTag):
class Tag(MediaTag):
class Writer(MediaTag):
class GuidTag(PlexObject):
class Guid(GuidTag):
class Review(PlexObject):
class BaseImage(PlexObject):
class Art(BaseImage):
class Banner(BaseImage):
class Poster(BaseImage):
class Chapter(PlexObject):
class Marker(PlexObject):
class Field(PlexObject):
class SearchResult(PlexObject):
class Agent(PlexObject):
class AgentMediaType(Agent):
    TAG = 'Media'
    TAG = 'Part'
    TAG = 'Stream'
    STREAMTYPE = 1
    TAG = 'Stream'
    STREAMTYPE = 2
    TAG = 'Stream'
    STREAMTYPE = 3
    TAG = 'Stream'
    STREAMTYPE = 4
    TAG = 'Session'
    TAG = 'TranscodeSession'
    TAG = 'TranscodeJob'
    TAG = 'Item'
    TAG = 'Video'
    TAG = 'Collection'
    FILTER = 'collection'
    TAG = 'Country'
    FILTER = 'country'
    TAG = 'Director'
    FILTER = 'director'
    TAG = 'Format'
    FILTER = 'format'
    TAG = 'Genre'
    FILTER = 'genre'
    TAG = 'Label'
    FILTER = 'label'
    TAG = 'Mood'
    FILTER = 'mood'
    TAG = 'Producer'
    FILTER = 'producer'
    TAG = 'Role'
    FILTER = 'role'
    TAG = 'Similar'
    FILTER = 'similar'
    TAG = 'Style'
    FILTER = 'style'
    TAG = 'Subformat'
    FILTER = 'subformat'
    TAG = 'Tag'
    FILTER = 'tag'
    TAG = 'Writer'
    FILTER = 'writer'
    TAG = 'Guid'
    TAG = 'Review'
    TAG = 'Photo'
    TAG = 'Chapter'
    TAG = 'Marker'
    TAG = 'Field'
    TAG = 'SearchResult'
    TAG = 'Agent'
    TAG = 'MediaType'
    def _loadData(self, data):
    def isOptimizedVersion(self):
    def delete(self):
    def _loadData(self, data):
    def _buildStreams(self, data):
    def hasPreviewThumbnails(self):
    def videoStreams(self):
    def audioStreams(self):
    def subtitleStreams(self):
    def lyricStreams(self):
    def setDefaultAudioStream(self, stream):
    def setDefaultSubtitleStream(self, stream):
    def resetDefaultSubtitleStream(self):
    def _loadData(self, data):
    def _loadData(self, data):
    def _loadData(self, data):
    def _loadData(self, data):
    def _loadData(self, data):
    def _loadData(self, data):
    def _loadData(self, data):
    def _loadData(self, data):
    def _loadData(self, data):
    def items(self):
    def remove(self):
    def rename(self, title):
    def reprocess(self, ratingKey):
    def _loadData(self, data):
    def remove(self):
    def move(self, after):
    def _loadData(self, data):
    def items(self):
    def collection(self):
    def _loadData(self, data):
    def _loadData(self, data):
    def _loadData(self, data):
    def select(self):
    def _loadData(self, data):
    def __repr__(self):
    def _loadData(self, data):
    def _loadData(self, data):
    def __repr__(self):
    def _loadData(self, data):
    def __repr__(self):
    def _loadData(self, data):
    def languageCode(self):
    def settings(self):
    def _settings(self):
    def __repr__(self):
    def _loadData(self, data):
    def languageCode(self):
plexapi/mixins.py
class PosterMixin(PosterUrlMixin):
    """ Mixin for Plex objects that can have posters. """

    def posters(self):
        """ Returns list of available :class:`~plexapi.media.Poster` objects. """
        return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey, cls=media.Poster)

    def uploadPoster(self, url=None, filepath=None):
        """ Upload a poster from a url or filepath.

            Parameters:
                url (str): The full URL to the image to upload.
                filepath (str): The full file path the the image to upload.
        """
        if url:
            key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url))
            self._server.query(key, method=self._server._session.post)
        elif filepath:
            key = '/library/metadata/%s/posters?' % self.ratingKey
            data = open(filepath, 'rb').read()
            self._server.query(key, method=self._server._session.post, data=data)

    def setPoster(self, poster):
        """ Set the poster for a Plex object.
        
            Parameters:
                poster (:class:`~plexapi.media.Poster`): The poster object to select.
        """
        poster.select()

    def lockPoster(self):
        """ Lock the poster for a Plex object. """
        self._edit(**{'thumb.locked': 1})

    def unlockPoster(self):
        """ Unlock the poster for a Plex object. """
        self._edit(**{'thumb.locked': 0})


Question: import re
from urllib.parse import quote_plus, unquote
from plexapi import media, utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection
from plexapi.mixins import ArtMixin, PosterMixin, SmartFilterMixin
from plexapi.playqueue import PlayQueue
from plexapi.utils import deprecated
        from plexapi.sync import SyncItem, Policy, MediaSettings
# -*- coding: utf-8 -*-



@utils.registerPlexObject
class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMixin):
    """ Represents a single Playlist.

        Attributes:
            TAG (str): 'Playlist'
            TYPE (str): 'playlist'
            addedAt (datetime): Datetime the playlist was added to the server.
            allowSync (bool): True if you allow syncing playlists.
            composite (str): URL to composite image (/playlist/<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.
            fields (List<:class:`~plexapi.media.Field`>): List of field objects.
            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.
            librarySectionID (int): Library section identifier (radio only)
            librarySectionKey (str): Library section key (radio only)
            librarySectionTitle (str): Library section title (radio only)
            playlistType (str): 'audio', 'video', or 'photo'
            radio (bool): If this playlist represents a radio station
            ratingKey (int): Unique key identifying the playlist.
            smart (bool): True if the playlist is a smart playlist.
            summary (str): Summary of the playlist.
            title (str): Name of the playlist.
            type (str): 'playlist'
            updatedAt (datatime): Datetime the playlist was updated.
    """
    TAG = 'Playlist'
    TYPE = 'playlist'

    def _loadData(self, data):
        """ Load attribute values from Plex XML response. """
        Playable._loadData(self, data)
        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.content = data.attrib.get('content')
        self.duration = utils.cast(int, data.attrib.get('duration'))
        self.durationInSeconds = utils.cast(int, data.attrib.get('durationInSeconds'))
        self.fields = self.findItems(data, media.Field)
        self.guid = data.attrib.get('guid')
        self.icon = data.attrib.get('icon')
        self.key = data.attrib.get('key', '').replace('/items', '')  # FIX_BUG_50
        self.leafCount = utils.cast(int, data.attrib.get('leafCount'))
        self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
        self.librarySectionKey = data.attrib.get('librarySectionKey')
        self.librarySectionTitle = data.attrib.get('librarySectionTitle')
        self.playlistType = data.attrib.get('playlistType')
        self.radio = utils.cast(bool, data.attrib.get('radio', 0))
        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 = utils.toDatetime(data.attrib.get('updatedAt'))
        self._items = None  # cache for self.items
        self._section = None  # cache for self.section
        self._filters = None  # cache for self.filters

    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 thumb(self):
        """ Alias to self.composite. """
        return self.composite

    @property
    def metadataType(self):
        """ Returns the type of metadata in the playlist (movie, track, or photo). """
        if self.isVideo:
            return 'movie'
        elif self.isAudio:
            return 'track'
        elif self.isPhoto:
            return 'photo'
        else:
            raise Unsupported('Unexpected playlist type')

    @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 _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

Answer:         raise NotFound('Item with title "%s" not found in the playlist' % item.title)