diff --git a/lib/charset_normalizer/md.py b/lib/charset_normalizer/md.py index b55e95c4..f3d6505c 100644 --- a/lib/charset_normalizer/md.py +++ b/lib/charset_normalizer/md.py @@ -314,7 +314,7 @@ class SuperWeirdWordPlugin(MessDetectorPlugin): self._buffer = "" self._buffer_accent_count = 0 elif ( - character not in {"<", ">", "-", "="} + character not in {"<", ">", "-", "=", "~", "|", "_"} and character.isdigit() is False and is_symbol(character) ): diff --git a/lib/charset_normalizer/version.py b/lib/charset_normalizer/version.py index 69bf0503..77cfff25 100644 --- a/lib/charset_normalizer/version.py +++ b/lib/charset_normalizer/version.py @@ -2,5 +2,5 @@ Expose version """ -__version__ = "2.0.11" +__version__ = "2.0.12" VERSION = __version__.split(".") diff --git a/lib/plexapi/__init__.py b/lib/plexapi/__init__.py index 06a1ee63..2a7d39ef 100644 --- a/lib/plexapi/__init__.py +++ b/lib/plexapi/__init__.py @@ -21,9 +21,9 @@ TIMEOUT = CONFIG.get('plexapi.timeout', 30, int) X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int) X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool) -# Plex Header Configuation +# Plex Header Configuration X_PLEX_PROVIDES = CONFIG.get('header.provides', 'controller') -X_PLEX_PLATFORM = CONFIG.get('header.platform', CONFIG.get('header.platorm', uname()[0])) +X_PLEX_PLATFORM = CONFIG.get('header.platform', CONFIG.get('header.platform', uname()[0])) X_PLEX_PLATFORM_VERSION = CONFIG.get('header.platform_version', uname()[2]) X_PLEX_PRODUCT = CONFIG.get('header.product', PROJECT) X_PLEX_VERSION = CONFIG.get('header.version', VERSION) diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index bef951d8..621183b3 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -2,12 +2,16 @@ import os from urllib.parse import quote_plus -from plexapi import library, media, utils +from plexapi import media, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest -from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin -from plexapi.mixins import RatingMixin, SplitMergeMixin, UnmatchMatchMixin -from plexapi.mixins import CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin +from plexapi.mixins import ( + AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, + ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, ThemeMixin, ThemeUrlMixin, + OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, + TrackArtistMixin, TrackDiscNumberMixin, TrackNumberMixin, + CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin +) from plexapi.playlist import Playlist @@ -38,7 +42,7 @@ class Audio(PlexPartialObject): title (str): Name of the artist, album, or track (Jason Mraz, We Sing, Lucky, etc.). titleSort (str): Title to use when sorting (defaults to title). type (str): 'artist', 'album', or 'track'. - updatedAt (datatime): Datetime the item was updated. + updatedAt (datetime): Datetime the item was updated. userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars). viewCount (int): Count of times the item was played. """ @@ -125,8 +129,13 @@ class Audio(PlexPartialObject): @utils.registerPlexObject -class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin, - CollectionMixin, CountryMixin, GenreMixin, MoodMixin, SimilarArtistMixin, StyleMixin): +class Artist( + Audio, + AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, + ArtMixin, PosterMixin, ThemeMixin, + SortTitleMixin, SummaryMixin, TitleMixin, + CollectionMixin, CountryMixin, GenreMixin, LabelMixin, MoodMixin, SimilarArtistMixin, StyleMixin +): """ Represents a single Artist. Attributes: @@ -138,9 +147,11 @@ class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, S countries (List<:class:`~plexapi.media.Country`>): List country objects. genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. key (str): API URL (/library/metadata/). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. locations (List): List of folder paths where the artist is found on disk. similar (List<:class:`~plexapi.media.Similar`>): List of similar objects. styles (List<:class:`~plexapi.media.Style`>): List of style objects. + theme (str): URL to theme resource (/library/metadata//theme/). """ TAG = 'Directory' TYPE = 'artist' @@ -153,26 +164,23 @@ class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, S self.countries = self.findItems(data, media.Country) self.genres = self.findItems(data, media.Genre) self.key = self.key.replace('/children', '') # FIX_BUG_50 + self.labels = self.findItems(data, media.Label) self.locations = self.listAttrs(data, 'path', etag='Location') self.similar = self.findItems(data, media.Similar) self.styles = self.findItems(data, media.Style) + self.theme = data.attrib.get('theme') def __iter__(self): for album in self.albums(): yield album - def hubs(self): - """ Returns a list of :class:`~plexapi.library.Hub` objects. """ - data = self._server.query(self._details_key) - return self.findItems(data, library.Hub, rtag='Related') - def album(self, title): """ Returns the :class:`~plexapi.audio.Album` that matches the specified title. Parameters: title (str): Title of the album to return. """ - key = '/library/metadata/%s/children' % self.ratingKey + key = f"/library/sections/{self.librarySectionID}/all?artist.id={self.ratingKey}&type=9" return self.fetchItem(key, Album, title__iexact=title) def albums(self, **kwargs): @@ -230,8 +238,13 @@ class Artist(Audio, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, S @utils.registerPlexObject -class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin, - CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin): +class Album( + Audio, + UnmatchMatchMixin, RatingMixin, + ArtMixin, PosterMixin, ThemeUrlMixin, + OriginallyAvailableMixin, SortTitleMixin, StudioMixin, SummaryMixin, TitleMixin, + CollectionMixin, GenreMixin, LabelMixin, MoodMixin, StyleMixin +): """ Represents a single Album. Attributes: @@ -248,6 +261,7 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin, parentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c). parentKey (str): API URL of the album artist (/library/metadata/). parentRatingKey (int): Unique key identifying the album artist. + parentTheme (str): URL to artist theme resource (/library/metadata//theme/). parentThumb (str): URL to album artist thumbnail image (/library/metadata//thumb/). parentTitle (str): Name of the album artist. rating (float): Album rating (7.9; 9.8; 8.1). @@ -274,6 +288,7 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin, self.parentGuid = data.attrib.get('parentGuid') self.parentKey = data.attrib.get('parentKey') self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) + self.parentTheme = data.attrib.get('parentTheme') self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.rating = utils.cast(float, data.attrib.get('rating')) @@ -298,10 +313,14 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin, :exc:`~plexapi.exceptions.BadRequest`: If title or track parameter is missing. """ key = '/library/metadata/%s/children' % self.ratingKey - if title is not None: + if title is not None and not isinstance(title, int): return self.fetchItem(key, Track, title__iexact=title) - elif track is not None: - return self.fetchItem(key, Track, parentTitle__iexact=self.title, index=track) + elif track is not None or isinstance(title, int): + if isinstance(title, int): + index = title + else: + index = track + return self.fetchItem(key, Track, parentTitle__iexact=self.title, index=index) raise BadRequest('Missing argument: title or track is required') def tracks(self, **kwargs): @@ -337,8 +356,13 @@ class Album(Audio, ArtMixin, PosterMixin, RatingMixin, UnmatchMatchMixin, @utils.registerPlexObject -class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin, - CollectionMixin, MoodMixin): +class Track( + Audio, Playable, + ExtrasMixin, RatingMixin, + ArtUrlMixin, PosterUrlMixin, ThemeUrlMixin, + TitleMixin, TrackArtistMixin, TrackNumberMixin, TrackDiscNumberMixin, + CollectionMixin, LabelMixin, MoodMixin +): """ Represents a single Track. Attributes: @@ -351,19 +375,23 @@ class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin, grandparentGuid (str): Plex GUID for the album artist (plex://artist/5d07bcb0403c64029053ac4c). grandparentKey (str): API URL of the album artist (/library/metadata/). grandparentRatingKey (int): Unique key identifying the album artist. + grandparentTheme (str): URL to artist theme resource (/library/metadata//theme/). + (/library/metadata//theme/). grandparentThumb (str): URL to album artist thumbnail image (/library/metadata//thumb/). grandparentTitle (str): Name of the album artist for the track. + labels (List<:class:`~plexapi.media.Label`>): List of label objects. media (List<:class:`~plexapi.media.Media`>): List of media objects. originalTitle (str): The artist for the track. parentGuid (str): Plex GUID for the album (plex://album/5d07cd8e403c640290f180f9). - parentIndex (int): Album index. + parentIndex (int): Disc number of the track. parentKey (str): API URL of the album (/library/metadata/). parentRatingKey (int): Unique key identifying the album. parentThumb (str): URL to album thumbnail image (/library/metadata//thumb/). parentTitle (str): Name of the album for the track. primaryExtraKey (str) API URL for the primary extra for the track. ratingCount (int): Number of ratings contributing to the rating score. + skipCount (int): Number of times the track has been skipped. viewOffset (int): View offset in milliseconds. year (int): Year the track was released. """ @@ -381,18 +409,21 @@ class Track(Audio, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin, self.grandparentGuid = data.attrib.get('grandparentGuid') self.grandparentKey = data.attrib.get('grandparentKey') self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) + self.grandparentTheme = data.attrib.get('grandparentTheme') self.grandparentThumb = data.attrib.get('grandparentThumb') self.grandparentTitle = data.attrib.get('grandparentTitle') + self.labels = self.findItems(data, media.Label) self.media = self.findItems(data, media.Media) self.originalTitle = data.attrib.get('originalTitle') self.parentGuid = data.attrib.get('parentGuid') - self.parentIndex = data.attrib.get('parentIndex') + self.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) self.parentKey = data.attrib.get('parentKey') self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.primaryExtraKey = data.attrib.get('primaryExtraKey') self.ratingCount = utils.cast(int, data.attrib.get('ratingCount')) + self.skipCount = utils.cast(int, data.attrib.get('skipCount')) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.year = utils.cast(int, data.attrib.get('year')) diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index 94bfe2ad..fb1066aa 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -53,7 +53,9 @@ class PlexObject(object): if data is not None: self._loadData(data) self._details_key = self._buildDetailsKey() - self._autoReload = False + self._overwriteNone = True + self._edits = None # Save batch edits for a single API call + self._autoReload = True # Automatically reload the object when accessing a missing attribute def __repr__(self): uid = self._clean(self.firstAttr('_baseurl', 'key', 'id', 'playQueueID', 'uri')) @@ -65,9 +67,9 @@ class PlexObject(object): if attr in _DONT_OVERWRITE_SESSION_KEYS and value == []: value = getattr(self, attr, []) - autoReload = self.__dict__.get('_autoReload') - # Don't overwrite an attr with None unless it's a private variable or not auto reload - if value is not None or attr.startswith('_') or attr not in self.__dict__ or not autoReload: + overwriteNone = self.__dict__.get('_overwriteNone') + # Don't overwrite an attr with None unless it's a private variable or overwrite None is True + if value is not None or attr.startswith('_') or attr not in self.__dict__ or overwriteNone: self.__dict__[attr] = value def _clean(self, value): @@ -169,9 +171,14 @@ class PlexObject(object): raise BadRequest('ekey was not provided') if isinstance(ekey, int): ekey = '/library/metadata/%s' % ekey - for elem in self._server.query(ekey): + data = self._server.query(ekey) + librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) + for elem in data: if self._checkAttrs(elem, **kwargs): - return self._buildItem(elem, cls, ekey) + item = self._buildItem(elem, cls, ekey) + if librarySectionID: + item.librarySectionID = librarySectionID + return item clsname = cls.__name__ if cls else 'None' raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs)) @@ -196,7 +203,7 @@ class PlexObject(object): Any XML attribute can be filtered when fetching results. Filtering is done before the Python objects are built to help keep things speedy. For example, passing in ``viewCount=0`` will only return matching items where the view count is ``0``. - Note that case matters when specifying attributes. Attributes futher down in the XML + Note that case matters when specifying attributes. Attributes further down in the XML tree can be filtered by *prepending* the attribute with each element tag ``Tag__``. Examples: @@ -228,12 +235,12 @@ class PlexObject(object): * ``__exists`` (*bool*): Value is or is not present in the attrs. * ``__gt``: Value is greater than specified arg. * ``__gte``: Value is greater than or equal to specified arg. - * ``__icontains``: Case insensative value contains specified arg. - * ``__iendswith``: Case insensative value ends with specified arg. - * ``__iexact``: Case insensative value matches specified arg. + * ``__icontains``: Case insensitive value contains specified arg. + * ``__iendswith``: Case insensitive value ends with specified arg. + * ``__iexact``: Case insensitive value matches specified arg. * ``__in``: Value is in a specified list or tuple. - * ``__iregex``: Case insensative value matches the specified regular expression. - * ``__istartswith``: Case insensative value starts with specified arg. + * ``__iregex``: Case insensitive value matches the specified regular expression. + * ``__istartswith``: Case insensitive value starts with specified arg. * ``__lt``: Value is less than specified arg. * ``__lte``: Value is less than or equal to specified arg. * ``__regex``: Value matches the specified regular expression. @@ -276,9 +283,9 @@ class PlexObject(object): kwargs['etag'] = cls.TAG if cls and cls.TYPE and 'type' not in kwargs: kwargs['type'] = cls.TYPE - # rtag to iter on a specific root tag + # rtag to iter on a specific root tag using breadth-first search if rtag: - data = next(data.iter(rtag), []) + data = next(utils.iterXMLBFS(data, rtag), []) # loop through all data elements to find matches items = [] for elem in data: @@ -298,9 +305,9 @@ class PlexObject(object): def listAttrs(self, data, attr, rtag=None, **kwargs): """ Return a list of values from matching attribute. """ results = [] - # rtag to iter on a specific root tag + # rtag to iter on a specific root tag using breadth-first search if rtag: - data = next(data.iter(rtag), []) + data = next(utils.iterXMLBFS(data, rtag), []) for elem in data: kwargs['%s__exists' % attr] = True if self._checkAttrs(elem, **kwargs): @@ -340,7 +347,7 @@ class PlexObject(object): """ return self._reload(key=key, **kwargs) - def _reload(self, key=None, _autoReload=False, **kwargs): + def _reload(self, key=None, _overwriteNone=True, **kwargs): """ Perform the actual reload. """ details_key = self._buildDetailsKey(**kwargs) if kwargs else self._details_key key = key or details_key or self.key @@ -348,9 +355,9 @@ class PlexObject(object): raise Unsupported('Cannot reload an object not built from a URL.') self._initpath = key data = self._server.query(key) - self._autoReload = _autoReload + self._overwriteNone = _overwriteNone self._loadData(data[0]) - self._autoReload = False + self._overwriteNone = True return self def _checkAttrs(self, elem, **kwargs): @@ -392,7 +399,7 @@ class PlexObject(object): # check were looking for the tag if attr.lower() == 'etag': return [elem.tag] - # loop through attrs so we can perform case-insensative match + # loop through attrs so we can perform case-insensitive match for _attr, value in elem.attrib.items(): if attr.lower() == _attr.lower(): return [value] @@ -414,6 +421,10 @@ class PlexObject(object): def _loadData(self, data): raise NotImplementedError('Abstract method not implemented.') + @property + def _searchType(self): + return self.TYPE + class PlexPartialObject(PlexObject): """ Not all objects in the Plex listings return the complete list of elements @@ -455,20 +466,21 @@ class PlexPartialObject(PlexObject): def __getattribute__(self, attr): # Dragons inside.. :-/ value = super(PlexPartialObject, self).__getattribute__(attr) - # Check a few cases where we dont want to reload + # Check a few cases where we don't 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 + if self._autoReload is False: 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) + self._reload() return super(PlexPartialObject, self).__getattribute__(attr) def analyze(self): @@ -507,44 +519,79 @@ class PlexPartialObject(PlexObject): def _edit(self, **kwargs): """ Actually edit an object. """ + if isinstance(self._edits, dict): + self._edits.update(kwargs) + return self + if 'id' not in kwargs: kwargs['id'] = self.ratingKey if 'type' not in kwargs: - kwargs['type'] = utils.searchType(self.type) + kwargs['type'] = utils.searchType(self._searchType) - part = '/library/sections/%s/all?%s' % (self.librarySectionID, - urlencode(kwargs)) + part = '/library/sections/%s/all%s' % (self.librarySectionID, + utils.joinArgs(kwargs)) self._server.query(part, method=self._server._session.put) + return self def edit(self, **kwargs): """ Edit an object. + Note: This is a low level method and you need to know all the field/tag keys. + See :class:`~plexapi.mixins.EditFieldMixin` and :class:`~plexapi.mixins.EditTagsMixin` + for individual field and tag editing methods. 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. + .. code-block:: python + + edits = { + 'type': 1, + 'id': movie.ratingKey, + 'title.value': 'A new title', + 'title.locked': 1, + 'summary.value': 'This is a summary.', + 'summary.locked': 1, + 'collection[0].tag.tag': 'A tag', + 'collection.locked': 1} + } + movie.edit(**edits) - 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) + return self._edit(**kwargs) + + def batchEdits(self): + """ Enable batch editing mode to save API calls. + Must call :func:`~plexapi.base.PlexPartialObject.saveEdits` at the end to save all the edits. + See :class:`~plexapi.mixins.EditFieldMixin` and :class:`~plexapi.mixins.EditTagsMixin` + for individual field and tag editing methods. + + Example: + + .. code-block:: python + + # Batch editing multiple fields and tags in a single API call + Movie.batchEdits() + Movie.editTitle('A New Title').editSummary('A new summary').editTagline('A new tagline') \\ + .addCollection('New Collection').removeGenre('Action').addLabel('Favorite') + Movie.saveEdits() + + """ + self._edits = {} + return self + + def saveEdits(self): + """ Save all the batch edits and automatically reload the object. + See :func:`~plexapi.base.PlexPartialObject.batchEdits` for details. + """ + if not isinstance(self._edits, dict): + raise BadRequest('Batch editing mode not enabled. Must call `batchEdits()` first.') + + edits = self._edits + self._edits = None + self._edit(**edits) + return self.reload() def refresh(self): """ Refreshing a Library or individual item causes the metadata for the item to be @@ -709,7 +756,7 @@ class Playable(object): filename = part.file if kwargs: - # So this seems to be a alot slower but allows transcode. + # So this seems to be a a lot slower but allows transcode. download_url = self.getStreamURL(**kwargs) else: download_url = self._server.url('%s?download=1' % part.key) @@ -746,7 +793,7 @@ class Playable(object): key = '/:/progress?key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s' % (self.ratingKey, time, state) self._server.query(key) - self._reload(_autoReload=True) + self._reload(_overwriteNone=False) def updateTimeline(self, time, state='stopped', duration=None): """ Set the timeline progress for this video. @@ -764,7 +811,7 @@ class Playable(object): key = '/:/timeline?ratingKey=%s&key=%s&identifier=com.plexapp.plugins.library&time=%d&state=%s%s' key %= (self.ratingKey, self.key, time, state, durationStr) self._server.query(key) - self._reload(_autoReload=True) + self._reload(_overwriteNone=False) class MediaContainer(PlexObject): diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py index 56f522d2..cc976a58 100644 --- a/lib/plexapi/client.py +++ b/lib/plexapi/client.py @@ -23,10 +23,10 @@ class PlexClient(PlexObject): server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional). data (ElementTree): Response from PlexServer used to build this object (optional). initpath (str): Path used to generate data. - baseurl (str): HTTP URL to connect dirrectly to this client. + baseurl (str): HTTP URL to connect directly to this client. identifier (str): The resource/machine identifier for the desired client. May be necessary when connecting to a specific proxied client (optional). - token (str): X-Plex-Token used for authenication (optional). + token (str): X-Plex-Token used for authentication (optional). session (:class:`~requests.Session`): requests.Session object if you want more control (optional). timeout (int): timeout in seconds on initial connect to client (default config.TIMEOUT). @@ -48,7 +48,7 @@ class PlexClient(PlexObject): session (:class:`~requests.Session`): Session object used for connection. state (str): Unknown title (str): Name of this client (Johns iPhone, etc). - token (str): X-Plex-Token used for authenication + token (str): X-Plex-Token used for authentication vendor (str): Unknown version (str): Device version (4.6.1, etc). _baseurl (str): HTTP address of the client. @@ -131,7 +131,7 @@ class PlexClient(PlexObject): self.platformVersion = data.attrib.get('platformVersion') self.title = data.attrib.get('title') or data.attrib.get('name') # Active session details - # Since protocolCapabilities is missing from /sessions we cant really control this player without + # Since protocolCapabilities is missing from /sessions we can't really control this player without # creating a client manually. # Add this in next breaking release. # if self._initpath == 'status/sessions': @@ -210,8 +210,8 @@ class PlexClient(PlexObject): controller = command.split('/')[0] headers = {'X-Plex-Target-Client-Identifier': self.machineIdentifier} if controller not in self.protocolCapabilities: - log.debug('Client %s doesnt support %s controller.' - 'What your trying might not work' % (self.title, controller)) + log.debug("Client %s doesn't support %s controller." + "What your trying might not work" % (self.title, controller)) proxy = self._proxyThroughServer if proxy is None else proxy query = self._server.query if proxy else self.query @@ -318,21 +318,21 @@ class PlexClient(PlexObject): Parameters: media (:class:`~plexapi.media.Media`): Media object to navigate to. **params (dict): Additional GET parameters to include with the command. - - Raises: - :exc:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object. """ - if not self._server: - raise Unsupported('A server must be specified before using this command.') server_url = media._server._baseurl.split(':') - self.sendCommand('mirror/details', **dict({ - 'machineIdentifier': self._server.machineIdentifier, + command = { + 'machineIdentifier': media._server.machineIdentifier, 'address': server_url[1].strip('/'), 'port': server_url[-1], 'key': media.key, 'protocol': server_url[0], - 'token': media._server.createToken() - }, **params)) + **params, + } + token = media._server.createToken() + if token: + command["token"] = token + + self.sendCommand("mirror/details", **command) # ------------------- # Playback Commands @@ -488,12 +488,7 @@ class PlexClient(PlexObject): representing the beginning (default 0). **params (dict): Optional additional parameters to include in the playback request. See also: https://github.com/plexinc/plex-media-player/wiki/Remote-control-API#modified-commands - - Raises: - :exc:`~plexapi.exceptions.Unsupported`: When no PlexServer specified in this object. """ - if not self._server: - raise Unsupported('A server must be specified before using this command.') server_url = media._server._baseurl.split(':') server_port = server_url[-1].strip('/') @@ -509,19 +504,24 @@ class PlexClient(PlexObject): if mediatype == "audio": mediatype = "music" - playqueue = media if isinstance(media, PlayQueue) else self._server.createPlayQueue(media) - self.sendCommand('playback/playMedia', **dict({ + playqueue = media if isinstance(media, PlayQueue) else media._server.createPlayQueue(media) + command = { 'providerIdentifier': 'com.plexapp.plugins.library', - 'machineIdentifier': self._server.machineIdentifier, + 'machineIdentifier': media._server.machineIdentifier, 'protocol': server_url[0], 'address': server_url[1].strip('/'), 'port': server_port, 'offset': offset, 'key': media.key or playqueue.selectedItem.key, - 'token': media._server.createToken(), 'type': mediatype, 'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID, - }, **params)) + **params, + } + token = media._server.createToken() + if token: + command["token"] = token + + self.sendCommand("playback/playMedia", **command) def setParameters(self, volume=None, shuffle=None, repeat=None, mtype=DEFAULT_MTYPE): """ Set multiple playback parameters at once. diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index cd9e52c1..44f1e1f7 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -5,14 +5,24 @@ from plexapi import media, utils from plexapi.base import PlexPartialObject from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection -from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin -from plexapi.mixins import LabelMixin, SmartFilterMixin +from plexapi.mixins import ( + AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin, + ArtMixin, PosterMixin, ThemeMixin, + ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, + LabelMixin +) from plexapi.playqueue import PlayQueue from plexapi.utils import deprecated @utils.registerPlexObject -class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin, SmartFilterMixin): +class Collection( + PlexPartialObject, + AdvancedSettingsMixin, SmartFilterMixin, HubsMixin, RatingMixin, + ArtMixin, PosterMixin, ThemeMixin, + ContentRatingMixin, SortTitleMixin, SummaryMixin, TitleMixin, + LabelMixin +): """ Represents a single Collection. Attributes: @@ -22,9 +32,10 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin art (str): URL to artwork image (/library/metadata//art/). artBlurHash (str): BlurHash string for artwork image. childCount (int): Number of items in the collection. - collectionMode (str): How the items in the collection are displayed. + collectionFilterBasedOnUser (int): Which user's activity is used for the collection filtering. + collectionMode (int): How the items in the collection are displayed. collectionPublished (bool): True if the collection is published to the Plex homepage. - collectionSort (str): How to sort the items in the collection. + collectionSort (int): How to sort the items in the collection. content (str): The filter URI string for smart collections. contentRating (str) Content rating (PG-13; NR; TV-G). fields (List<:class:`~plexapi.media.Field`>): List of field objects. @@ -43,12 +54,13 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin smart (bool): True if the collection is a smart collection. subtype (str): Media type of the items in the collection (movie, show, artist, or album). summary (str): Summary of the collection. + theme (str): URL to theme resource (/library/metadata//theme/). thumb (str): URL to thumbnail image (/library/metadata//thumb/). thumbBlurHash (str): BlurHash string for thumbnail image. title (str): Name of the collection. titleSort (str): Title to use when sorting (defaults to title). type (str): 'collection' - updatedAt (datatime): Datetime the collection was updated. + updatedAt (datetime): Datetime the collection was updated. userRating (float): Rating of the collection (0.0 - 10.0) equaling (0 stars - 5 stars). """ TAG = 'Directory' @@ -60,6 +72,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') self.childCount = utils.cast(int, data.attrib.get('childCount')) + self.collectionFilterBasedOnUser = utils.cast(int, data.attrib.get('collectionFilterBasedOnUser', '0')) self.collectionMode = utils.cast(int, data.attrib.get('collectionMode', '-1')) self.collectionPublished = utils.cast(bool, data.attrib.get('collectionPublished', '0')) self.collectionSort = utils.cast(int, data.attrib.get('collectionSort', '0')) @@ -81,6 +94,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin self.smart = utils.cast(bool, data.attrib.get('smart', '0')) self.subtype = data.attrib.get('subtype') self.summary = data.attrib.get('summary') + self.theme = data.attrib.get('theme') self.thumb = data.attrib.get('thumb') self.thumbBlurHash = data.attrib.get('thumbBlurHash') self.title = data.attrib.get('title') @@ -184,6 +198,32 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin """ Alias to :func:`~plexapi.library.Collection.item`. """ return self.item(title) + def filterUserUpdate(self, user=None): + """ Update the collection filtering user advanced setting. + + Parameters: + user (str): One of the following values: + "admin" (Always the server admin user), + "user" (User currently viewing the content) + + Example: + + .. code-block:: python + + collection.updateMode(user="user") + """ + if not self.smart: + raise BadRequest('Cannot change collection filtering user for a non-smart collection.') + + user_dict = { + 'admin': 0, + 'user': 1 + } + key = user_dict.get(user) + if key is None: + raise BadRequest('Unknown collection filtering user: %s. Options %s' % (user, list(user_dict))) + self.editAdvanced(collectionFilterBasedOnUser=key) + def modeUpdate(self, mode=None): """ Update the collection mode advanced setting. @@ -208,7 +248,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin } key = mode_dict.get(mode) if key is None: - raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict))) + raise BadRequest('Unknown collection mode: %s. Options %s' % (mode, list(mode_dict))) self.editAdvanced(collectionMode=key) def sortUpdate(self, sort=None): @@ -216,7 +256,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin Parameters: sort (str): One of the following values: - "realease" (Order Collection by realease dates), + "release" (Order Collection by release dates), "alpha" (Order Collection alphabetically), "custom" (Custom collection order) @@ -226,6 +266,9 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin collection.updateSort(mode="alpha") """ + if self.smart: + raise BadRequest('Cannot change collection order for a smart collection.') + sort_dict = { 'release': 0, 'alpha': 1, @@ -340,6 +383,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin })) self._server.query(key, method=self._server._session.put) + @deprecated('use editTitle, editSortTitle, editContentRating, and editSummary instead') def edit(self, title=None, titleSort=None, contentRating=None, summary=None, **kwargs): """ Edit the collection. @@ -364,7 +408,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin args['summary.locked'] = 1 args.update(kwargs) - super(Collection, self).edit(**args) + self._edit(**args) def delete(self): """ Delete the collection. """ diff --git a/lib/plexapi/config.py b/lib/plexapi/config.py index e78fa193..77bb8953 100644 --- a/lib/plexapi/config.py +++ b/lib/plexapi/config.py @@ -62,4 +62,5 @@ def reset_base_headers(): 'X-Plex-Device-Name': plexapi.X_PLEX_DEVICE_NAME, 'X-Plex-Client-Identifier': plexapi.X_PLEX_IDENTIFIER, 'X-Plex-Sync-Version': '2', + 'X-Plex-Features': 'external-media', } diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index 72ad282f..80d34806 100644 --- a/lib/plexapi/const.py +++ b/lib/plexapi/const.py @@ -3,7 +3,7 @@ # Library version MAJOR_VERSION = 4 -MINOR_VERSION = 9 -PATCH_VERSION = 2 +MINOR_VERSION = 11 +PATCH_VERSION = 0 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/lib/plexapi/gdm.py b/lib/plexapi/gdm.py index b2214e9e..b9edac08 100644 --- a/lib/plexapi/gdm.py +++ b/lib/plexapi/gdm.py @@ -15,7 +15,7 @@ import struct class GDM: """Base class to discover GDM services. - Atrributes: + Attributes: entries (List): List of server and/or client data discovered. """ diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index c67ae9b7..41892ef9 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -43,7 +43,7 @@ class Library(PlexObject): if elem.attrib.get('type') == cls.TYPE: section = cls(self._server, elem, key) self._sectionsByID[section.key] = section - self._sectionsByTitle[section.title.lower()] = section + self._sectionsByTitle[section.title.lower().strip()] = section def sections(self): """ Returns a list of all media sections in this library. Library sections may be any of @@ -59,10 +59,11 @@ class Library(PlexObject): Parameters: title (str): Title of the section to return. """ - if not self._sectionsByTitle or title not in self._sectionsByTitle: + normalized_title = title.lower().strip() + if not self._sectionsByTitle or normalized_title not in self._sectionsByTitle: self._loadSections() try: - return self._sectionsByTitle[title.lower()] + return self._sectionsByTitle[normalized_title] except KeyError: raise NotFound('Invalid library section: %s' % title) from None @@ -125,7 +126,7 @@ class Library(PlexObject): def search(self, title=None, libtype=None, **kwargs): """ Searching within a library section is much more powerful. It seems certain attributes on the media objects can be targeted to filter this search down - a bit, but I havent found the documentation for it. + a bit, but I haven't found the documentation for it. Example: "studio=Comedy%20Central" or "year=1999" "title=Kung Fu" all work. Other items such as actor= seem to work, but require you already know the id of the actor. @@ -396,7 +397,7 @@ class LibrarySection(PlexObject): 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. + # Private attrs as we don't want a reload. self._filterTypes = None self._fieldTypes = None self._totalViewSize = None @@ -599,12 +600,13 @@ class LibrarySection(PlexObject): return self.fetchItem(key, title__iexact=title) def getGuid(self, guid): - """ Returns the media item with the specified external IMDB, TMDB, or TVDB ID. + """ Returns the media item with the specified external Plex, 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``. + Examples: Plex ``plex://show/5d9c086c46115600200aa2fe`` + IMDB ``imdb://tt0944947``, TMDB ``tmdb://1399``, TVDB ``tvdb://121361``. Raises: :exc:`~plexapi.exceptions.NotFound`: The guid is not found in the library. @@ -613,21 +615,32 @@ class LibrarySection(PlexObject): .. code-block:: python - result1 = library.getGuid('imdb://tt0944947') - result2 = library.getGuid('tmdb://1399') - result3 = library.getGuid('tvdb://121361') + result1 = library.getGuid('plex://show/5d9c086c46115600200aa2fe') + result2 = library.getGuid('imdb://tt0944947') + result3 = library.getGuid('tmdb://1399') + result4 = 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'] + guidLookup = {} + for item in library.all(): + guidLookup[item.guid] = item + guidLookup.update({guid.id for guid in item.guids}} + + result1 = guidLookup['plex://show/5d9c086c46115600200aa2fe'] + result2 = guidLookup['imdb://tt0944947'] + result4 = guidLookup['tmdb://1399'] + result5 = 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] + if guid.startswith('plex://'): + result = self.search(guid=guid)[0] + return result + else: + 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 @@ -1271,7 +1284,7 @@ class LibrarySection(PlexObject): * 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, + The following filter fields are just some examples of the possible filters. The list is not exhaustive, and not all filters apply to all library types. * **actor** (:class:`~plexapi.media.MediaTag`): Search for the name of an actor. @@ -1334,7 +1347,7 @@ class LibrarySection(PlexObject): 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``. + type is assumed. For example, in a TV show library ``viewCount`` 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 @@ -2236,16 +2249,61 @@ class FilteringType(PlexObject): self.title = data.attrib.get('title') self.type = data.attrib.get('type') - # Add additional manual sorts and fields which are available + self._librarySectionID = self._parent().key + + # Add additional manual filters, sorts, and fields which are available # but not exposed on the Plex server + self.filters += self._manualFilters() self.sorts += self._manualSorts() self.fields += self._manualFields() + def _manualFilters(self): + """ Manually add additional filters which are available + but not exposed on the Plex server. + """ + # Filters: (filter, type, title) + additionalFilters = [ + ] + + if self.type == 'season': + additionalFilters.extend([ + ('label', 'string', 'Labels') + ]) + elif self.type == 'episode': + additionalFilters.extend([ + ('label', 'string', 'Labels') + ]) + elif self.type == 'artist': + additionalFilters.extend([ + ('label', 'string', 'Labels') + ]) + elif self.type == 'track': + additionalFilters.extend([ + ('label', 'string', 'Labels') + ]) + elif self.type == 'collection': + additionalFilters.extend([ + ('label', 'string', 'Labels') + ]) + + manualFilters = [] + for filterTag, filterType, filterTitle in additionalFilters: + filterKey = '/library/sections/%s/%s?type=%s' % ( + self._librarySectionID, filterTag, utils.searchType(self.type) + ) + filterXML = ( + '' + % (filterTag, filterType, filterKey, filterTitle) + ) + manualFilters.append(self._manuallyLoadXML(filterXML, FilteringFilter)) + + return manualFilters + def _manualSorts(self): """ Manually add additional sorts which are available but not exposed on the Plex server. """ - # Sorts: key, dir, title + # Sorts: (key, dir, title) additionalSorts = [ ('guid', 'asc', 'Guid'), ('id', 'asc', 'Rating Key'), @@ -2275,8 +2333,10 @@ class FilteringType(PlexObject): manualSorts = [] for sortField, sortDir, sortTitle in additionalSorts: - sortXML = ('' - % (sortDir, sortField, sortField, sortTitle)) + sortXML = ( + '' + % (sortDir, sortField, sortField, sortTitle) + ) manualSorts.append(self._manuallyLoadXML(sortXML, FilteringSort)) return manualSorts @@ -2285,7 +2345,7 @@ class FilteringType(PlexObject): """ Manually add additional fields which are available but not exposed on the Plex server. """ - # Fields: key, type, title + # Fields: (key, type, title) additionalFields = [ ('guid', 'string', 'Guid'), ('id', 'integer', 'Rating Key'), @@ -2311,31 +2371,41 @@ class FilteringType(PlexObject): additionalFields.extend([ ('addedAt', 'date', 'Date Season Added'), ('unviewedLeafCount', 'integer', 'Episode Unplayed Count'), - ('year', 'integer', 'Season Year') + ('year', 'integer', 'Season Year'), + ('label', 'tag', 'Label') ]) elif self.type == 'episode': additionalFields.extend([ ('audienceRating', 'integer', 'Audience Rating'), ('duration', 'integer', 'Duration'), ('rating', 'integer', 'Critic Rating'), - ('viewOffset', 'integer', 'View Offset') + ('viewOffset', 'integer', 'View Offset'), + ('label', 'tag', 'Label') + ]) + elif self.type == 'artist': + additionalFields.extend([ + ('label', 'tag', 'Label') ]) elif self.type == 'track': additionalFields.extend([ ('duration', 'integer', 'Duration'), - ('viewOffset', 'integer', 'View Offset') + ('viewOffset', 'integer', 'View Offset'), + ('label', 'tag', 'Label') ]) elif self.type == 'collection': additionalFields.extend([ - ('addedAt', 'date', 'Date Added') + ('addedAt', 'date', 'Date Added'), + ('label', 'tag', 'Label') ]) prefix = '' if self.type == 'movie' else self.type + '.' manualFields = [] for field, fieldType, fieldTitle in additionalFields: - fieldXML = ('' - % (prefix, field, fieldTitle, fieldType)) + fieldXML = ( + '' + % (prefix, field, fieldTitle, fieldType) + ) manualFields.append(self._manuallyLoadXML(fieldXML, FilteringField)) return manualFields diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 87c33383..23ac7700 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -39,7 +39,7 @@ class Media(PlexObject): : The following attributes are only available for photos. - * aperture (str): The apeture used to take the photo. + * aperture (str): The aperture used to take the photo. * exposure (str): The exposure used to take the photo. * iso (int): The iso used to take the photo. * lens (str): The lens used to take the photo. @@ -93,7 +93,7 @@ class Media(PlexObject): try: return self._server.query(part, method=self._server._session.delete) except BadRequest: - log.error("Failed to delete %s. This could be because you havn't allowed " + log.error("Failed to delete %s. This could be because you haven't allowed " "items to be deleted" % part) raise @@ -224,7 +224,7 @@ class MediaPartStream(PlexObject): id (int): The unique ID for this stream on the server. index (int): The index of the stream. language (str): The language of the stream (ex: English, ไทย). - languageCode (str): The Ascii language code of the stream (ex: eng, tha). + languageCode (str): The ASCII language code of the stream (ex: eng, tha). requiredBandwidths (str): The required bandwidths to stream the file. selected (bool): True if this stream is selected. streamType (int): The stream type (1= :class:`~plexapi.media.VideoStream`, @@ -283,8 +283,8 @@ class VideoStream(MediaPartStream): duration (int): The duration of video stream in milliseconds. frameRate (float): The frame rate of the video stream (ex: 23.976). frameRateMode (str): The frame rate mode of the video stream. - hasScallingMatrix (bool): True if video stream has a scaling matrix. - height (int): The hight of the video stream in pixels (ex: 1080). + hasScalingMatrix (bool): True if video stream has a scaling matrix. + height (int): The height of the video stream in pixels (ex: 1080). level (int): The codec encoding level of the video stream (ex: 41). profile (str): The profile of the video stream (ex: asp). pixelAspectRatio (str): The pixel aspect ratio of the video stream. @@ -323,7 +323,7 @@ class VideoStream(MediaPartStream): self.duration = utils.cast(int, data.attrib.get('duration')) self.frameRate = utils.cast(float, data.attrib.get('frameRate')) self.frameRateMode = data.attrib.get('frameRateMode') - self.hasScallingMatrix = utils.cast(bool, data.attrib.get('hasScallingMatrix')) + self.hasScalingMatrix = utils.cast(bool, data.attrib.get('hasScalingMatrix')) self.height = utils.cast(int, data.attrib.get('height')) self.level = utils.cast(int, data.attrib.get('level')) self.profile = data.attrib.get('profile') @@ -400,7 +400,7 @@ class SubtitleStream(MediaPartStream): container (str): The container of the subtitle stream. forced (bool): True if this is a forced subtitle. format (str): The format of the subtitle stream (ex: srt). - headerCommpression (str): The header compression of the subtitle stream. + headerCompression (str): The header compression of the subtitle stream. transient (str): Unknown. """ TAG = 'Stream' @@ -468,7 +468,7 @@ class TranscodeSession(PlexObject): audioDecision (str): The transcode decision for the audio stream. complete (bool): True if the transcode is complete. container (str): The container of the transcoded media. - context (str): The context for the transcode sesson. + context (str): The context for the transcode session. duration (int): The duration of the transcoded media in milliseconds. height (int): The height of the transcoded media in pixels. key (str): API URL (ex: /transcode/sessions/). @@ -572,7 +572,7 @@ class Optimized(PlexObject): """ key = '%s/%s/items' % (self._initpath, self.id) return self.fetchItems(key) - + def remove(self): """ Remove an Optimized item""" key = '%s/%s' % (self._initpath, self.id) @@ -893,7 +893,7 @@ class Guid(GuidTag): @utils.registerPlexObject class Review(PlexObject): """ Represents a single Review for a Movie. - + Attributes: TAG (str): 'Review' filter (str): filter for reviews? @@ -917,19 +917,17 @@ class Review(PlexObject): self.text = data.attrib.get('text') -class BaseImage(PlexObject): - """ Base class for all Art, Banner, and Poster objects. +class BaseResource(PlexObject): + """ Base class for all Art, Banner, Poster, and Theme objects. Attributes: - TAG (str): 'Photo' + TAG (str): 'Photo' or 'Track' key (str): API URL (/library/metadata/). - provider (str): The source of the poster or art. - ratingKey (str): Unique key identifying the poster or art. - selected (bool): True if the poster or art is currently selected. - thumb (str): The URL to retrieve the poster or art thumbnail. + provider (str): The source of the art or poster, None for Theme objects. + ratingKey (str): Unique key identifying the resource. + selected (bool): True if the resource is currently selected. + thumb (str): The URL to retrieve the resource thumbnail. """ - TAG = 'Photo' - def _loadData(self, data): self._data = data self.key = data.attrib.get('key') @@ -947,16 +945,24 @@ class BaseImage(PlexObject): pass -class Art(BaseImage): +class Art(BaseResource): """ Represents a single Art object. """ + TAG = 'Photo' -class Banner(BaseImage): +class Banner(BaseResource): """ Represents a single Banner object. """ + TAG = 'Photo' -class Poster(BaseImage): +class Poster(BaseResource): """ Represents a single Poster object. """ + TAG = 'Photo' + + +class Theme(BaseResource): + """ Represents a single Theme object. """ + TAG = 'Track' @utils.registerPlexObject @@ -1106,3 +1112,41 @@ class AgentMediaType(Agent): @deprecated('use "languageCodes" instead') def languageCode(self): return self.languageCodes + + +@utils.registerPlexObject +class Availability(PlexObject): + """ Represents a single online streaming service Availability. + + Attributes: + TAG (str): 'Availability' + country (str): The streaming service country. + offerType (str): Subscription, buy, or rent from the streaming service. + platform (str): The platform slug for the streaming service. + platformColorThumb (str): Thumbnail icon for the streaming service. + platformInfo (str): The streaming service platform info. + platformUrl (str): The URL to the media on the streaming service. + price (float): The price to buy or rent from the streaming service. + priceDescription (str): The display price to buy or rent from the streaming service. + quality (str): The video quality on the streaming service. + title (str): The title of the streaming service. + url (str): The Plex availability URL. + """ + TAG = 'Availability' + + def __repr__(self): + return f'<{self.__class__.__name__}:{self.platform}:{self.offerType}>' + + def _loadData(self, data): + self._data = data + self.country = data.attrib.get('country') + self.offerType = data.attrib.get('offerType') + self.platform = data.attrib.get('platform') + self.platformColorThumb = data.attrib.get('platformColorThumb') + self.platformInfo = data.attrib.get('platformInfo') + self.platformUrl = data.attrib.get('platformUrl') + self.price = utils.cast(float, data.attrib.get('price')) + self.priceDescription = data.attrib.get('priceDescription') + self.quality = data.attrib.get('quality') + self.title = data.attrib.get('title') + self.url = data.attrib.get('url') diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index af5d1da5..906ab81d 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -1,8 +1,11 @@ # -*- coding: utf-8 -*- +from datetime import datetime + from urllib.parse import parse_qsl, quote_plus, unquote, urlencode, urlsplit from plexapi import media, settings, utils from plexapi.exceptions import BadRequest, NotFound +from plexapi.utils import deprecated class AdvancedSettingsMixin(object): @@ -57,176 +60,64 @@ class AdvancedSettingsMixin(object): self._server.query(url, method=self._server._session.put) -class ArtUrlMixin(object): - """ Mixin for Plex objects that can have a background artwork url. """ +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 - @property - def artUrl(self): - """ Return the art url for the Plex object. """ - art = self.firstAttr('art', 'grandparentArt') - return self._server.url(art, includeToken=True) if art else None + 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 = [] -class ArtMixin(ArtUrlMixin): - """ Mixin for Plex objects that can have background artwork. """ + 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 - 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}) - - -class BannerUrlMixin(object): - """ Mixin for Plex objects that can have a banner url. """ - - @property - def bannerUrl(self): - """ Return the banner url for the Plex object. """ - banner = self.firstAttr('banner') - return self._server.url(banner, includeToken=True) if banner else None - - -class BannerMixin(BannerUrlMixin): - """ Mixin for Plex objects that can have banners. """ - - def banners(self): - """ Returns list of available :class:`~plexapi.media.Banner` objects. """ - return self.fetchItems('/library/metadata/%s/banners' % self.ratingKey, cls=media.Banner) - - def uploadBanner(self, url=None, filepath=None): - """ Upload a banner 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/banners?url=%s' % (self.ratingKey, quote_plus(url)) - self._server.query(key, method=self._server._session.post) - elif filepath: - key = '/library/metadata/%s/banners?' % self.ratingKey - data = open(filepath, 'rb').read() - self._server.query(key, method=self._server._session.post, data=data) - - def setBanner(self, banner): - """ Set the banner for a Plex object. - - Parameters: - banner (:class:`~plexapi.media.Banner`): The banner object to select. - """ - banner.select() - - def lockBanner(self): - """ Lock the banner for a Plex object. """ - self._edit(**{'banner.locked': 1}) - - def unlockBanner(self): - """ Unlock the banner for a Plex object. """ - self._edit(**{'banner.locked': 0}) - - -class PosterUrlMixin(object): - """ Mixin for Plex objects that can have a poster url. """ - - @property - def thumbUrl(self): - """ Return the thumb url for the Plex object. """ - thumb = self.firstAttr('thumb', 'parentThumb', 'granparentThumb') - return self._server.url(thumb, includeToken=True) if thumb else None - - @property - def posterUrl(self): - """ Alias to self.thumbUrl. """ - return self.thumbUrl - - -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}) - - -class RatingMixin(object): - """ Mixin for Plex objects that can have user star ratings. """ - - def rate(self, rating=None): - """ Rate the Plex object. Note: Plex ratings are displayed out of 5 stars (e.g. rating 7.0 = 3.5 stars). - - Parameters: - rating (float, optional): Rating from 0 to 10. Exclude to reset the rating. - - Raises: - :exc:`~plexapi.exceptions.BadRequest`: If the rating is invalid. - """ - if rating is None: - rating = -1 - elif not isinstance(rating, (int, float)) or rating < 0 or rating > 10: - raise BadRequest('Rating must be between 0 to 10.') - key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rating) - self._server.query(key, method=self._server._session.put) + return {filterOp: rules} class SplitMergeMixin(object): @@ -343,303 +234,815 @@ class UnmatchMatchMixin(object): self._server.query(data, method=self._server._session.put) -class CollectionMixin(object): +class ExtrasMixin(object): + """ Mixin for Plex objects that can have extras. """ + + def extras(self): + """ Returns a list of :class:`~plexapi.video.Extra` objects. """ + from plexapi.video import Extra + data = self._server.query(self._details_key) + return self.findItems(data, Extra, rtag='Extras') + + +class HubsMixin(object): + """ Mixin for Plex objects that can have related hubs. """ + + def hubs(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. """ + from plexapi.library import Hub + data = self._server.query(self._details_key) + return self.findItems(data, Hub, rtag='Related') + + +class RatingMixin(object): + """ Mixin for Plex objects that can have user star ratings. """ + + def rate(self, rating=None): + """ Rate the Plex object. Note: Plex ratings are displayed out of 5 stars (e.g. rating 7.0 = 3.5 stars). + + Parameters: + rating (float, optional): Rating from 0 to 10. Exclude to reset the rating. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: If the rating is invalid. + """ + if rating is None: + rating = -1 + elif not isinstance(rating, (int, float)) or rating < 0 or rating > 10: + raise BadRequest('Rating must be between 0 to 10.') + key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rating) + self._server.query(key, method=self._server._session.put) + + +class ArtUrlMixin(object): + """ Mixin for Plex objects that can have a background artwork url. """ + + @property + def artUrl(self): + """ Return the art url for the Plex object. """ + art = self.firstAttr('art', 'grandparentArt') + return self._server.url(art, includeToken=True) if art else None + + +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. """ + return self._edit(**{'art.locked': 1}) + + def unlockArt(self): + """ Unlock the background artwork for a Plex object. """ + return self._edit(**{'art.locked': 0}) + + +class BannerUrlMixin(object): + """ Mixin for Plex objects that can have a banner url. """ + + @property + def bannerUrl(self): + """ Return the banner url for the Plex object. """ + banner = self.firstAttr('banner') + return self._server.url(banner, includeToken=True) if banner else None + + +class BannerMixin(BannerUrlMixin): + """ Mixin for Plex objects that can have banners. """ + + def banners(self): + """ Returns list of available :class:`~plexapi.media.Banner` objects. """ + return self.fetchItems('/library/metadata/%s/banners' % self.ratingKey, cls=media.Banner) + + def uploadBanner(self, url=None, filepath=None): + """ Upload a banner 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/banners?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/banners?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setBanner(self, banner): + """ Set the banner for a Plex object. + + Parameters: + banner (:class:`~plexapi.media.Banner`): The banner object to select. + """ + banner.select() + + def lockBanner(self): + """ Lock the banner for a Plex object. """ + return self._edit(**{'banner.locked': 1}) + + def unlockBanner(self): + """ Unlock the banner for a Plex object. """ + return self._edit(**{'banner.locked': 0}) + + +class PosterUrlMixin(object): + """ Mixin for Plex objects that can have a poster url. """ + + @property + def thumbUrl(self): + """ Return the thumb url for the Plex object. """ + thumb = self.firstAttr('thumb', 'parentThumb', 'grandparentThumb') + return self._server.url(thumb, includeToken=True) if thumb else None + + @property + def posterUrl(self): + """ Alias to self.thumbUrl. """ + return self.thumbUrl + + +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. """ + return self._edit(**{'thumb.locked': 1}) + + def unlockPoster(self): + """ Unlock the poster for a Plex object. """ + return self._edit(**{'thumb.locked': 0}) + + +class ThemeUrlMixin(object): + """ Mixin for Plex objects that can have a theme url. """ + + @property + def themeUrl(self): + """ Return the theme url for the Plex object. """ + theme = self.firstAttr('theme', 'parentTheme', 'grandparentTheme') + return self._server.url(theme, includeToken=True) if theme else None + + +class ThemeMixin(ThemeUrlMixin): + """ Mixin for Plex objects that can have themes. """ + + def themes(self): + """ Returns list of available :class:`~plexapi.media.Theme` objects. """ + return self.fetchItems('/library/metadata/%s/themes' % self.ratingKey, cls=media.Theme) + + def uploadTheme(self, url=None, filepath=None): + """ Upload a theme from url or filepath. + + Warning: Themes cannot be deleted using PlexAPI! + + Parameters: + url (str): The full URL to the theme to upload. + filepath (str): The full file path to the theme to upload. + """ + if url: + key = '/library/metadata/%s/themes?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/themes?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setTheme(self, theme): + raise NotImplementedError( + 'Themes cannot be set through the API. ' + 'Re-upload the theme using "uploadTheme" to set it.' + ) + + def lockTheme(self): + """ Lock the theme for a Plex object. """ + self._edit(**{'theme.locked': 1}) + + def unlockTheme(self): + """ Unlock the theme for a Plex object. """ + self._edit(**{'theme.locked': 0}) + + +class EditFieldMixin(object): + """ Mixin for editing Plex object fields. """ + + def editField(self, field, value, locked=True, **kwargs): + """ Edit the field of a Plex object. All field editing methods can be chained together. + Also see :func:`~plexapi.base.PlexPartialObject.batchEdits` for batch editing fields. + + Parameters: + field (str): The name of the field to edit. + value (str): The value to edit the field to. + locked (bool): True (default) to lock the field, False to unlock the field. + + Example: + + .. code-block:: python + + # Chaining multiple field edits with reloading + Movie.editTitle('A New Title').editSummary('A new summary').editTagline('A new tagline').reload() + + """ + edits = { + '%s.value' % field: value or '', + '%s.locked' % field: 1 if locked else 0 + } + edits.update(kwargs) + return self._edit(**edits) + + +class ContentRatingMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a content rating. """ + + def editContentRating(self, contentRating, locked=True): + """ Edit the content rating. + + Parameters: + contentRating (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('contentRating', contentRating, locked=locked) + + +class OriginallyAvailableMixin(EditFieldMixin): + """ Mixin for Plex objects that can have an originally available date. """ + + def editOriginallyAvailable(self, originallyAvailable, locked=True): + """ Edit the originally available date. + + Parameters: + originallyAvailable (str or datetime): The new value (YYYY-MM-DD) or datetime object. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + if isinstance(originallyAvailable, datetime): + originallyAvailable = originallyAvailable.strftime('%Y-%m-%d') + return self.editField('originallyAvailableAt', originallyAvailable, locked=locked) + + +class OriginalTitleMixin(EditFieldMixin): + """ Mixin for Plex objects that can have an original title. """ + + def editOriginalTitle(self, originalTitle, locked=True): + """ Edit the original title. + + Parameters: + originalTitle (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('originalTitle', originalTitle, locked=locked) + + +class SortTitleMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a sort title. """ + + def editSortTitle(self, sortTitle, locked=True): + """ Edit the sort title. + + Parameters: + sortTitle (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('titleSort', sortTitle, locked=locked) + + +class StudioMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a studio. """ + + def editStudio(self, studio, locked=True): + """ Edit the studio. + + Parameters: + studio (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('studio', studio, locked=locked) + + +class SummaryMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a summary. """ + + def editSummary(self, summary, locked=True): + """ Edit the summary. + + Parameters: + summary (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('summary', summary, locked=locked) + + +class TaglineMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a tagline. """ + + def editTagline(self, tagline, locked=True): + """ Edit the tagline. + + Parameters: + tagline (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('tagline', tagline, locked=locked) + + +class TitleMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a title. """ + + def editTitle(self, title, locked=True): + """ Edit the title. + + Parameters: + title (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + kwargs = {} + if self.TYPE == 'album': + # Editing album title also requires the artist ratingKey + kwargs['artist.id.value'] = self.parentRatingKey + return self.editField('title', title, locked=locked, **kwargs) + + +class TrackArtistMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a track artist. """ + + def editTrackArtist(self, trackArtist, locked=True): + """ Edit the track artist. + + Parameters: + trackArtist (str): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('originalTitle', trackArtist, locked=locked) + + +class TrackNumberMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a track number. """ + + def editTrackNumber(self, trackNumber, locked=True): + """ Edit the track number. + + Parameters: + trackNumber (int): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('index', trackNumber, locked=locked) + + +class TrackDiscNumberMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a track disc number. """ + + def editDiscNumber(self, discNumber, locked=True): + """ Edit the track disc number. + + Parameters: + discNumber (int): The new value. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + return self.editField('parentIndex', discNumber, locked=locked) + + +class PhotoCapturedTimeMixin(EditFieldMixin): + """ Mixin for Plex objects that can have a captured time. """ + + def editCapturedTime(self, capturedTime, locked=True): + """ Edit the photo captured time. + + Parameters: + capturedTime (str or datetime): The new value (YYYY-MM-DD hh:mm:ss) or datetime object. + locked (bool): True (default) to lock the field, False to unlock the field. + """ + if isinstance(capturedTime, datetime): + capturedTime = capturedTime.strftime('%Y-%m-%d %H:%M:%S') + return self.editField('originallyAvailableAt', capturedTime, locked=locked) + + +class EditTagsMixin(object): + """ Mixin for editing Plex object tags. """ + + @deprecated('use "editTags" instead') + def _edit_tags(self, tag, items, locked=True, remove=False): + return self.editTags(tag, items, locked, remove) + + def editTags(self, tag, items, locked=True, remove=False, **kwargs): + """ Edit the tags of a Plex object. All tag editing methods can be chained together. + Also see :func:`~plexapi.base.PlexPartialObject.batchEdits` for batch editing tags. + + Parameters: + tag (str): Name of the tag to edit. + items (List): List of tags to add or remove. + locked (bool): True (default) to lock the tags, False to unlock the tags. + remove (bool): True to remove the tags in items. + + Example: + + .. code-block:: python + + # Chaining multiple tag edits with reloading + Show.addCollection('New Collection').removeGenre('Action').addLabel('Favorite').reload() + + """ + if not isinstance(items, list): + items = [items] + + value = getattr(self, self._tagPlural(tag)) + existing_tags = [t.tag for t in value if t and remove is False] + edits = self._tagHelper(self._tagSingular(tag), existing_tags + items, locked, remove) + edits.update(kwargs) + return self._edit(**edits) + + @staticmethod + def _tagSingular(tag): + """ Return the singular name of a tag. """ + if tag == 'countries': + return 'country' + elif tag == 'similar': + return 'similar' + elif tag[-1] == 's': + return tag[:-1] + return tag + + @staticmethod + def _tagPlural(tag): + """ Return the plural name of a tag. """ + if tag == 'country': + return 'countries' + elif tag == 'similar': + return 'similar' + elif tag[-1] != 's': + return tag + 's' + return tag + + @staticmethod + def _tagHelper(tag, items, locked=True, remove=False): + """ Return a dict of the query parameters for editing a tag. """ + if not isinstance(items, list): + items = [items] + + data = { + '%s.locked' % tag: 1 if locked else 0 + } + + if remove: + tagname = '%s[].tag.tag-' % tag + data[tagname] = ','.join(items) + else: + for i, item in enumerate(items): + tagname = '%s[%s].tag.tag' % (tag, i) + data[tagname] = item + + return data + + +class CollectionMixin(EditTagsMixin): """ Mixin for Plex objects that can have collections. """ def addCollection(self, collections, locked=True): """ Add a collection tag(s). - Parameters: + Parameters: collections (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('collection', collections, locked=locked) + return self.editTags('collection', collections, locked=locked) def removeCollection(self, collections, locked=True): """ Remove a collection tag(s). - Parameters: + Parameters: collections (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('collection', collections, locked=locked, remove=True) + return self.editTags('collection', collections, locked=locked, remove=True) -class CountryMixin(object): +class CountryMixin(EditTagsMixin): """ Mixin for Plex objects that can have countries. """ def addCountry(self, countries, locked=True): """ Add a country tag(s). - Parameters: + Parameters: countries (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('country', countries, locked=locked) + return self.editTags('country', countries, locked=locked) def removeCountry(self, countries, locked=True): """ Remove a country tag(s). - Parameters: + Parameters: countries (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('country', countries, locked=locked, remove=True) + return self.editTags('country', countries, locked=locked, remove=True) -class DirectorMixin(object): +class DirectorMixin(EditTagsMixin): """ Mixin for Plex objects that can have directors. """ def addDirector(self, directors, locked=True): """ Add a director tag(s). - Parameters: + Parameters: directors (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('director', directors, locked=locked) + return self.editTags('director', directors, locked=locked) def removeDirector(self, directors, locked=True): """ Remove a director tag(s). - Parameters: + Parameters: directors (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('director', directors, locked=locked, remove=True) + return self.editTags('director', directors, locked=locked, remove=True) -class GenreMixin(object): +class GenreMixin(EditTagsMixin): """ Mixin for Plex objects that can have genres. """ def addGenre(self, genres, locked=True): """ Add a genre tag(s). - Parameters: + Parameters: genres (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('genre', genres, locked=locked) + return self.editTags('genre', genres, locked=locked) def removeGenre(self, genres, locked=True): """ Remove a genre tag(s). - Parameters: + Parameters: genres (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('genre', genres, locked=locked, remove=True) + return self.editTags('genre', genres, locked=locked, remove=True) -class LabelMixin(object): +class LabelMixin(EditTagsMixin): """ Mixin for Plex objects that can have labels. """ def addLabel(self, labels, locked=True): """ Add a label tag(s). - Parameters: + Parameters: labels (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('label', labels, locked=locked) + return self.editTags('label', labels, locked=locked) def removeLabel(self, labels, locked=True): """ Remove a label tag(s). - Parameters: + Parameters: labels (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('label', labels, locked=locked, remove=True) + return self.editTags('label', labels, locked=locked, remove=True) -class MoodMixin(object): +class MoodMixin(EditTagsMixin): """ Mixin for Plex objects that can have moods. """ def addMood(self, moods, locked=True): """ Add a mood tag(s). - Parameters: + Parameters: moods (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('mood', moods, locked=locked) + return self.editTags('mood', moods, locked=locked) def removeMood(self, moods, locked=True): """ Remove a mood tag(s). - Parameters: + Parameters: moods (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('mood', moods, locked=locked, remove=True) + return self.editTags('mood', moods, locked=locked, remove=True) -class ProducerMixin(object): +class ProducerMixin(EditTagsMixin): """ Mixin for Plex objects that can have producers. """ def addProducer(self, producers, locked=True): """ Add a producer tag(s). - Parameters: + Parameters: producers (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('producer', producers, locked=locked) + return self.editTags('producer', producers, locked=locked) def removeProducer(self, producers, locked=True): """ Remove a producer tag(s). - Parameters: + Parameters: producers (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('producer', producers, locked=locked, remove=True) + return self.editTags('producer', producers, locked=locked, remove=True) -class SimilarArtistMixin(object): +class SimilarArtistMixin(EditTagsMixin): """ Mixin for Plex objects that can have similar artists. """ def addSimilarArtist(self, artists, locked=True): """ Add a similar artist tag(s). - Parameters: + Parameters: artists (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('similar', artists, locked=locked) + return self.editTags('similar', artists, locked=locked) def removeSimilarArtist(self, artists, locked=True): """ Remove a similar artist tag(s). - Parameters: + Parameters: artists (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('similar', artists, locked=locked, remove=True) + return self.editTags('similar', artists, locked=locked, remove=True) -class StyleMixin(object): +class StyleMixin(EditTagsMixin): """ Mixin for Plex objects that can have styles. """ def addStyle(self, styles, locked=True): """ Add a style tag(s). - Parameters: + Parameters: styles (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('style', styles, locked=locked) + return self.editTags('style', styles, locked=locked) def removeStyle(self, styles, locked=True): """ Remove a style tag(s). - Parameters: + Parameters: styles (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('style', styles, locked=locked, remove=True) + return self.editTags('style', styles, locked=locked, remove=True) -class TagMixin(object): +class TagMixin(EditTagsMixin): """ Mixin for Plex objects that can have tags. """ def addTag(self, tags, locked=True): """ Add a tag(s). - Parameters: + Parameters: tags (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('tag', tags, locked=locked) + return self.editTags('tag', tags, locked=locked) def removeTag(self, tags, locked=True): """ Remove a tag(s). - Parameters: + Parameters: tags (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('tag', tags, locked=locked, remove=True) + return self.editTags('tag', tags, locked=locked, remove=True) -class WriterMixin(object): +class WriterMixin(EditTagsMixin): """ Mixin for Plex objects that can have writers. """ def addWriter(self, writers, locked=True): """ Add a writer tag(s). - Parameters: + Parameters: writers (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('writer', writers, locked=locked) + return self.editTags('writer', writers, locked=locked) def removeWriter(self, writers, locked=True): """ Remove a writer tag(s). - Parameters: + Parameters: writers (list): List of strings. locked (bool): True (default) to lock the field, False to unlock the field. """ - self._edit_tags('writer', writers, locked=locked, remove=True) + return self.editTags('writer', writers, locked=locked, remove=True) -class SmartFilterMixin(object): - """ Mixing for Plex objects that can have smart filters. """ +class WatchlistMixin(object): + """ Mixin for Plex objects that can be added to a user's watchlist. """ - 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:] + def onWatchlist(self, account=None): + """ Returns True if the item is on the user's watchlist. + Also see :func:`~plexapi.myplex.MyPlexAccount.onWatchlist`. - 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() + Parameters: + account (:class:`~plexapi.myplex.MyPlexAccount`, optional): Account to check item on the watchlist. + Note: This is required if you are not connected to a Plex server instance using the admin account. + """ + try: + account = account or self._server.myPlexAccount() + except AttributeError: + account = self._server + return account.onWatchlist(self) - filterOp = 'and' - rules = [] + def addToWatchlist(self, account=None): + """ Add this item to the specified user's watchlist. + Also see :func:`~plexapi.myplex.MyPlexAccount.addToWatchlist`. - 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 + Parameters: + account (:class:`~plexapi.myplex.MyPlexAccount`, optional): Account to add item to the watchlist. + Note: This is required if you are not connected to a Plex server instance using the admin account. + """ + try: + account = account or self._server.myPlexAccount() + except AttributeError: + account = self._server + account.addToWatchlist(self) - return {filterOp: rules} + def removeFromWatchlist(self, account=None): + """ Remove this item from the specified user's watchlist. + Also see :func:`~plexapi.myplex.MyPlexAccount.removeFromWatchlist`. + + Parameters: + account (:class:`~plexapi.myplex.MyPlexAccount`, optional): Account to remove item from the watchlist. + Note: This is required if you are not connected to a Plex server instance using the admin account. + """ + try: + account = account or self._server.myPlexAccount() + except AttributeError: + account = self._server + account.removeFromWatchlist(self) + + def streamingServices(self, account=None): + """ Return a list of :class:`~plexapi.media.Availability` + objects for the available streaming services for this item. + + Parameters: + account (:class:`~plexapi.myplex.MyPlexAccount`, optional): Account used to retrieve availability. + Note: This is required if you are not connected to a Plex server instance using the admin account. + """ + try: + account = account or self._server.myPlexAccount() + except AttributeError: + account = self._server + ratingKey = self.guid.rsplit('/', 1)[-1] + data = account.query(f"{account.METADATA}/library/metadata/{ratingKey}/availabilities") + return self.findItems(data) diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index 312050a9..a05a74bd 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import copy +import html import threading import time from xml.etree import ElementTree @@ -52,7 +53,7 @@ class MyPlexAccount(PlexObject): roles: (List) Lit of account roles. Plexpass membership listed here. scrobbleTypes (str): Description secure (bool): Description - subscriptionActive (bool): True if your subsctiption is active. + subscriptionActive (bool): True if your subscription is active. subscriptionFeatures: (List) List of features allowed on your subscription. subscriptionPlan (str): Name of subscription plan. subscriptionStatus (str): String representation of `subscriptionActive`. @@ -72,14 +73,12 @@ class MyPlexAccount(PlexObject): REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data - OPTOUTS = 'https://plex.tv/api/v2/user/%(userUUID)s/settings/opt_outs' # get + OPTOUTS = 'https://plex.tv/api/v2/user/{userUUID}/settings/opt_outs' # get LINK = 'https://plex.tv/api/v2/pins/link' # put # Hub sections - VOD = 'https://vod.provider.plex.tv/' # get - WEBSHOWS = 'https://webshows.provider.plex.tv/' # get - NEWS = 'https://news.provider.plex.tv/' # get - PODCASTS = 'https://podcasts.provider.plex.tv/' # get - MUSIC = 'https://music.provider.plex.tv/' # get + VOD = 'https://vod.provider.plex.tv' # get + MUSIC = 'https://music.provider.plex.tv' # get + METADATA = 'https://metadata.provider.plex.tv' # Key may someday switch to the following url. For now the current value works. # https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId} key = 'https://plex.tv/users/account' @@ -182,6 +181,8 @@ class MyPlexAccount(PlexObject): raise NotFound(message) else: raise BadRequest(message) + if headers.get('Accept') == 'application/json': + return response.json() data = response.text.encode('utf8') return ElementTree.fromstring(data) if data.strip() else None @@ -228,7 +229,7 @@ class MyPlexAccount(PlexObject): of the user to be added. server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier containing the library sections to share. - sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objects, or names to be shared (default None). `sections` must be defined in order to update shared libraries. allowSync (Bool): Set True to allow user to sync content. allowCameraUpload (Bool): Set True to allow user to upload photos. @@ -268,7 +269,7 @@ class MyPlexAccount(PlexObject): of the user to be added. server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier containing the library sections to share. - sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objects, or names to be shared (default None). `sections` must be defined in order to update shared libraries. allowSync (Bool): Set True to allow user to sync content. allowCameraUpload (Bool): Set True to allow user to upload photos. @@ -317,7 +318,7 @@ class MyPlexAccount(PlexObject): of the user to be added. server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier containing the library sections to share. - sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objects, or names to be shared (default None). `sections` must be defined in order to update shared libraries. allowSync (Bool): Set True to allow user to sync content. allowCameraUpload (Bool): Set True to allow user to upload photos. @@ -420,7 +421,7 @@ class MyPlexAccount(PlexObject): of the user to be updated. server (:class:`~plexapi.server.PlexServer`): `PlexServer` object, or machineIdentifier containing the library sections to share. - sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objecs, or names + sections (List<:class:`~plexapi.library.LibrarySection`>): List of `LibrarySection` objects, or names to be shared (default None). `sections` must be defined in order to update shared libraries. removeSections (Bool): Set True to remove all shares. Supersedes sections. allowSync (Bool): Set True to allow user to sync content. @@ -565,7 +566,7 @@ class MyPlexAccount(PlexObject): """ Converts friend filters to a string representation for transport. """ values = [] for key, vals in filterDict.items(): - if key not in ('contentRating', 'label'): + if key not in ('contentRating', 'label', 'contentRating!', 'label!'): raise BadRequest('Unknown filter key: %s', key) values.append('%s=%s' % (key, '%2C'.join(vals))) return '|'.join(values) @@ -614,7 +615,7 @@ class MyPlexAccount(PlexObject): clientId (str): an identifier of a client to query SyncItems for. If both `client` and `clientId` provided the client would be preferred. - If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier. + If neither `client` nor `clientId` provided the clientId would be set to current clients's identifier. """ if client: clientId = client.clientIdentifier @@ -635,14 +636,14 @@ class MyPlexAccount(PlexObject): sync_item (:class:`~plexapi.sync.SyncItem`): prepared SyncItem object with all fields set. If both `client` and `clientId` provided the client would be preferred. - If neither `client` nor `clientId` provided the clientId would be set to current clients`s identifier. + If neither `client` nor `clientId` provided the clientId would be set to current clients's identifier. Returns: :class:`~plexapi.sync.SyncItem`: an instance of created syncItem. Raises: - :exc:`~plexapi.exceptions.BadRequest`: When client with provided clientId wasn`t found. - :exc:`~plexapi.exceptions.BadRequest`: Provided client doesn`t provides `sync-target`. + :exc:`~plexapi.exceptions.BadRequest`: When client with provided clientId wasn't found. + :exc:`~plexapi.exceptions.BadRequest`: Provided client doesn't provides `sync-target`. """ if not client and not clientId: clientId = X_PLEX_IDENTIFIER @@ -657,7 +658,7 @@ class MyPlexAccount(PlexObject): raise BadRequest('Unable to find client by clientId=%s', clientId) if 'sync-target' not in client.provides: - raise BadRequest('Received client doesn`t provides sync-target') + raise BadRequest("Received client doesn't provides sync-target") params = { 'SyncItem[title]': sync_item.title, @@ -698,6 +699,7 @@ class MyPlexAccount(PlexObject): def history(self, maxresults=9999999, mindate=None): """ Get Play History for all library sections on all servers for the owner. + Parameters: maxresults (int): Only return the specified number of results (optional). mindate (datetime): Min datetime to return results from. @@ -709,47 +711,155 @@ class MyPlexAccount(PlexObject): hist.extend(conn.history(maxresults=maxresults, mindate=mindate, accountID=1)) return hist + def onlineMediaSources(self): + """ Returns a list of user account Online Media Sources settings :class:`~plexapi.myplex.AccountOptOut` + """ + url = self.OPTOUTS.format(userUUID=self.uuid) + elem = self.query(url) + return self.findItems(elem, cls=AccountOptOut, etag='optOut') + def videoOnDemand(self): """ Returns a list of VOD Hub items :class:`~plexapi.library.Hub` """ - req = requests.get(self.VOD + 'hubs/', headers={'X-Plex-Token': self._token}) - elem = ElementTree.fromstring(req.text) - return self.findItems(elem) - - def webShows(self): - """ Returns a list of Webshow Hub items :class:`~plexapi.library.Hub` - """ - req = requests.get(self.WEBSHOWS + 'hubs/', headers={'X-Plex-Token': self._token}) - elem = ElementTree.fromstring(req.text) - return self.findItems(elem) - - def news(self): - """ Returns a list of News Hub items :class:`~plexapi.library.Hub` - """ - req = requests.get(self.NEWS + 'hubs/sections/all', headers={'X-Plex-Token': self._token}) - elem = ElementTree.fromstring(req.text) - return self.findItems(elem) - - def podcasts(self): - """ Returns a list of Podcasts Hub items :class:`~plexapi.library.Hub` - """ - req = requests.get(self.PODCASTS + 'hubs/', headers={'X-Plex-Token': self._token}) - elem = ElementTree.fromstring(req.text) - return self.findItems(elem) + data = self.query(f'{self.VOD}/hubs') + return self.findItems(data) def tidal(self): """ Returns a list of tidal Hub items :class:`~plexapi.library.Hub` """ - req = requests.get(self.MUSIC + 'hubs/', headers={'X-Plex-Token': self._token}) - elem = ElementTree.fromstring(req.text) - return self.findItems(elem) + data = self.query(f'{self.MUSIC}/hubs') + return self.findItems(data) + + def watchlist(self, filter=None, sort=None, libtype=None, **kwargs): + """ Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` items in the user's watchlist. + Note: The objects returned are from Plex's online metadata. To get the matching item on a Plex server, + search for the media using the guid. + + Parameters: + filter (str, optional): 'available' or 'released' to only return items that are available or released, + otherwise return all items. + sort (str, optional): In the format ``field:dir``. Available fields are ``watchlistedAt`` (Added At), + ``titleSort`` (Title), ``originallyAvailableAt`` (Release Date), or ``rating`` (Critic Rating). + ``dir`` can be ``asc`` or ``desc``. + libtype (str, optional): 'movie' or 'show' to only return movies or shows, otherwise return all items. + **kwargs (dict): Additional custom filters to apply to the search results. + + + Example: + + .. code-block:: python + + # Watchlist for released movies sorted by critic rating in descending order + watchlist = account.watchlist(filter='released', sort='rating:desc', libtype='movie') + item = watchlist[0] # First item in the watchlist + + # Search for the item on a Plex server + result = plex.library.search(guid=item.guid, libtype=item.type) - def onlineMediaSources(self): - """ Returns a list of user account Online Media Sources settings :class:`~plexapi.myplex.AccountOptOut` """ - url = self.OPTOUTS % {'userUUID': self.uuid} - elem = self.query(url) - return self.findItems(elem, cls=AccountOptOut, etag='optOut') + params = { + 'includeCollections': 1, + 'includeExternalMedia': 1 + } + + if not filter: + filter = 'all' + if sort: + params['sort'] = sort + if libtype: + params['type'] = utils.searchType(libtype) + + params.update(kwargs) + data = self.query(f'{self.METADATA}/library/sections/watchlist/{filter}', params=params) + return self.findItems(data) + + def onWatchlist(self, item): + """ Returns True if the item is on the user's watchlist. + + Parameters: + item (:class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show`): Item to check + if it is on the user's watchlist. + """ + ratingKey = item.guid.rsplit('/', 1)[-1] + data = self.query(f"{self.METADATA}/library/metadata/{ratingKey}/userState") + return bool(data.find('UserState').attrib.get('watchlistedAt')) + + def addToWatchlist(self, items): + """ Add media items to the user's watchlist + + Parameters: + items (List): List of :class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show` + objects to be added to the watchlist. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When trying to add invalid or existing + media to the watchlist. + """ + if not isinstance(items, list): + items = [items] + + for item in items: + if self.onWatchlist(item): + raise BadRequest('"%s" is already on the watchlist' % item.title) + ratingKey = item.guid.rsplit('/', 1)[-1] + self.query(f'{self.METADATA}/actions/addToWatchlist?ratingKey={ratingKey}', method=self._session.put) + + def removeFromWatchlist(self, items): + """ Remove media items from the user's watchlist + + Parameters: + items (List): List of :class:`~plexapi.video.Movie` or :class:`~plexapi.video.Show` + objects to be added to the watchlist. + + Raises: + :exc:`~plexapi.exceptions.BadRequest`: When trying to remove invalid or non-existing + media to the watchlist. + """ + if not isinstance(items, list): + items = [items] + + for item in items: + if not self.onWatchlist(item): + raise BadRequest('"%s" is not on the watchlist' % item.title) + ratingKey = item.guid.rsplit('/', 1)[-1] + self.query(f'{self.METADATA}/actions/removeFromWatchlist?ratingKey={ratingKey}', method=self._session.put) + + def searchDiscover(self, query, limit=30): + """ Search for movies and TV shows in Discover. + Returns a list of :class:`~plexapi.video.Movie` and :class:`~plexapi.video.Show` objects. + + Parameters: + query (str): Search query. + limit (int, optional): Limit to the specified number of results. Default 30. + """ + headers = { + 'Accept': 'application/json' + } + params = { + 'query': query, + 'limit ': limit, + 'searchTypes': 'movies,tv', + 'includeMetadata': 1 + } + + data = self.query(f'{self.METADATA}/library/search', headers=headers, params=params) + searchResults = data['MediaContainer'].get('SearchResult', []) + + results = [] + for result in searchResults: + metadata = result['Metadata'] + type = metadata['type'] + if type == 'movie': + tag = 'Video' + elif type == 'show': + tag = 'Directory' + else: + continue + attrs = ''.join(f'{k}="{html.escape(str(v))}" ' for k, v in metadata.items()) + xml = f'<{tag} {attrs}/>' + results.append(self._manuallyLoadXML(xml)) + + return results def link(self, pin): """ Link a device to the account using a pin code. @@ -790,7 +900,7 @@ class MyPlexUser(PlexObject): restricted (str): Unknown. servers (List<:class:`~plexapi.myplex.)): Servers shared with the user. thumb (str): Link to the users avatar. - title (str): Seems to be an aliad for username. + title (str): Seems to be an alias for username. username (str): User's username. """ TAG = 'User' @@ -1103,7 +1213,7 @@ class MyPlexResource(PlexObject): :exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource. """ connections = self.preferred_connections(ssl, timeout, locations, schemes) - # Try connecting to all known resource connections in parellel, but + # Try connecting to all known resource connections in parallel, but # only return the first server (in order) that provides a response. cls = PlexServer if 'server' in self.provides else PlexClient listargs = [[cls, url, self.accessToken, timeout] for url in connections] @@ -1215,7 +1325,7 @@ class MyPlexDevice(PlexObject): """ Returns an instance of :class:`~plexapi.sync.SyncList` for current device. Raises: - :exc:`~plexapi.exceptions.BadRequest`: when the device doesn`t provides `sync-target`. + :exc:`~plexapi.exceptions.BadRequest`: when the device doesn't provides `sync-target`. """ if 'sync-target' not in self.provides: raise BadRequest('Requested syncList for device which do not provides sync-target') diff --git a/lib/plexapi/photo.py b/lib/plexapi/photo.py index c24d7fb1..cb6329ea 100644 --- a/lib/plexapi/photo.py +++ b/lib/plexapi/photo.py @@ -5,11 +5,21 @@ from urllib.parse import quote_plus from plexapi import media, utils, video from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest -from plexapi.mixins import ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, RatingMixin, TagMixin +from plexapi.mixins import ( + RatingMixin, + ArtUrlMixin, ArtMixin, PosterUrlMixin, PosterMixin, + SortTitleMixin, SummaryMixin, TitleMixin, PhotoCapturedTimeMixin, + TagMixin +) @utils.registerPlexObject -class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin): +class Photoalbum( + PlexPartialObject, + RatingMixin, + ArtMixin, PosterMixin, + SortTitleMixin, SummaryMixin, TitleMixin +): """ Represents a single Photoalbum (collection of photos). Attributes: @@ -33,11 +43,12 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin): title (str): Name of the photo album. (Trip to Disney World) titleSort (str): Title to use when sorting (defaults to title). type (str): 'photo' - updatedAt (datatime): Datetime the photo album was updated. + updatedAt (datetime): Datetime the photo album was updated. userRating (float): Rating of the photo album (0.0 - 10.0) equaling (0 stars - 5 stars). """ TAG = 'Directory' TYPE = 'photo' + _searchType = 'photoalbum' def _loadData(self, data): """ Load attribute values from Plex XML response. """ @@ -109,7 +120,7 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin): return self.episode(title) def download(self, savepath=None, keep_original_name=False, subfolders=False): - """ Download all photos and clips from the photo ablum. See :func:`~plexapi.base.Playable.download` for details. + """ Download all photos and clips from the photo album. See :func:`~plexapi.base.Playable.download` for details. Parameters: savepath (str): Defaults to current working dir. @@ -131,7 +142,13 @@ class Photoalbum(PlexPartialObject, ArtMixin, PosterMixin, RatingMixin): @utils.registerPlexObject -class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixin, TagMixin): +class Photo( + PlexPartialObject, Playable, + RatingMixin, + ArtUrlMixin, PosterUrlMixin, + PhotoCapturedTimeMixin, SortTitleMixin, SummaryMixin, TitleMixin, + TagMixin +): """ Represents a single Photo. Attributes: @@ -164,7 +181,7 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixi title (str): Name of the photo. titleSort (str): Title to use when sorting (defaults to title). type (str): 'photo' - updatedAt (datatime): Datetime the photo was updated. + updatedAt (datetime): Datetime the photo was updated. userRating (float): Rating of the photo (0.0 - 10.0) equaling (0 stars - 5 stars). year (int): Year the photo was taken. """ @@ -223,7 +240,7 @@ class Photo(PlexPartialObject, Playable, ArtUrlMixin, PosterUrlMixin, RatingMixi elif self.parentKey: return self._server.library.sectionByID(self.photoalbum().librarySectionID) else: - raise BadRequest('Unable to get section for photo, can`t find librarySectionID') + raise BadRequest("Unable to get section for photo, can't find librarySectionID") @property def locations(self): diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index ee4694d2..5241e261 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -6,13 +6,17 @@ 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.mixins import SmartFilterMixin, ArtMixin, PosterMixin from plexapi.playqueue import PlayQueue from plexapi.utils import deprecated @utils.registerPlexObject -class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMixin): +class Playlist( + PlexPartialObject, Playable, + SmartFilterMixin, + ArtMixin, PosterMixin +): """ Represents a single Playlist. Attributes: @@ -39,7 +43,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMi summary (str): Summary of the playlist. title (str): Name of the playlist. type (str): 'playlist' - updatedAt (datatime): Datetime the playlist was updated. + updatedAt (datetime): Datetime the playlist was updated. """ TAG = 'Playlist' TYPE = 'playlist' diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index cb672197..e5c36ad6 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -314,7 +314,7 @@ class PlexServer(PlexObject): def myPlexAccount(self): """ Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same token to access this server. If you are not the owner of this PlexServer - you're likley to recieve an authentication error calling this. + you're likely to receive an authentication error calling this. """ if self._myPlexAccount is None: from plexapi.myplex import MyPlexAccount @@ -323,7 +323,7 @@ class PlexServer(PlexObject): def _myPlexClientPorts(self): """ Sometimes the PlexServer does not properly advertise port numbers required - to connect. This attemps to look up device port number from plex.tv. + to connect. This attempts to look up device port number from plex.tv. See issue #126: Make PlexServer.clients() more user friendly. https://github.com/pkkid/python-plexapi/issues/126 """ @@ -393,7 +393,6 @@ class PlexServer(PlexObject): """ if isinstance(path, Path): path = path.path - path = os.path.normpath(path) paths = [p.path for p in self.browse(os.path.dirname(path), includeFiles=False)] return path in paths @@ -524,14 +523,39 @@ class PlexServer(PlexObject): filepath = utils.download(url, self._token, None, savepath, self._session, unpack=unpack) return filepath + def butlerTasks(self): + """ Return a list of :class:`~plexapi.base.ButlerTask` objects. """ + return self.fetchItems('/butler') + + def runButlerTask(self, task): + """ Manually run a butler task immediately instead of waiting for the scheduled task to run. + Note: The butler task is run asynchronously. Check Plex Web to monitor activity. + + Parameters: + task (str): The name of the task to run. (e.g. 'BackupDatabase') + + Example: + + .. code-block:: python + + availableTasks = [task.name for task in plex.butlerTasks()] + print("Available butler tasks:", availableTasks) + """ + validTasks = [task.name for task in self.butlerTasks()] + if task not in validTasks: + raise BadRequest( + f'Invalid butler task: {task}. Available tasks are: {validTasks}' + ) + self.query(f'/butler/{task}', method=self._session.post) + @deprecated('use "checkForUpdate" instead') def check_for_update(self, force=True, download=False): - return self.checkForUpdate() + return self.checkForUpdate(force=force, download=download) def checkForUpdate(self, force=True, download=False): """ Returns a :class:`~plexapi.base.Release` object containing release info. - Parameters: + Parameters: force (bool): Force server to check for new releases download (bool): Download if a update is available. """ @@ -730,7 +754,7 @@ class PlexServer(PlexObject): return self.fetchItems('/transcode/sessions') def startAlertListener(self, callback=None): - """ Creates a websocket connection to the Plex Server to optionally recieve + """ Creates a websocket connection to the Plex Server to optionally receive notifications. These often include messages from Plex about media scans as well as updates to currently running Transcode Sessions. @@ -738,7 +762,7 @@ class PlexServer(PlexObject): >> pip install websocket-client Parameters: - callback (func): Callback function to call on recieved messages. + callback (func): Callback function to call on received messages. Raises: :exc:`~plexapi.exception.Unsupported`: Websocket-client not installed. @@ -1078,7 +1102,7 @@ class SystemDevice(PlexObject): Attributes: TAG (str): 'Device' clientIdentifier (str): The unique identifier for the device. - createdAt (datatime): Datetime the device was created. + createdAt (datetime): Datetime the device was created. id (int): The ID of the device (not the same as :class:`~plexapi.myplex.MyPlexDevice` ID). key (str): API URL (/devices/) name (str): The name of the device. @@ -1102,11 +1126,11 @@ class StatisticsBandwidth(PlexObject): Attributes: TAG (str): 'StatisticsBandwidth' accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID. - at (datatime): Datetime of the bandwidth data. - bytes (int): The total number of bytes for the specified timespan. + at (datetime): Datetime of the bandwidth data. + bytes (int): The total number of bytes for the specified time span. deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID. - lan (bool): True or False wheter the bandwidth is local or remote. - timespan (int): The timespan for the bandwidth data. + lan (bool): True or False whether the bandwidth is local or remote. + timespan (int): The time span for the bandwidth data. 1: months, 2: weeks, 3: days, 4: hours, 6: seconds. """ @@ -1143,12 +1167,12 @@ class StatisticsResources(PlexObject): Attributes: TAG (str): 'StatisticsResources' - at (datatime): Datetime of the resource data. + at (datetime): Datetime of the resource data. hostCpuUtilization (float): The system CPU usage %. hostMemoryUtilization (float): The Plex Media Server CPU usage %. processCpuUtilization (float): The system RAM usage %. processMemoryUtilization (float): The Plex Media Server RAM usage %. - timespan (int): The timespan for the resource data (6: seconds). + timespan (int): The time span for the resource data (6: seconds). """ TAG = 'StatisticsResources' @@ -1166,3 +1190,28 @@ class StatisticsResources(PlexObject): self.__class__.__name__, self._clean(int(self.at.timestamp())) ] if p]) + + +@utils.registerPlexObject +class ButlerTask(PlexObject): + """ Represents a single scheduled butler task. + + Attributes: + TAG (str): 'ButlerTask' + description (str): The description of the task. + enabled (bool): Whether the task is enabled. + interval (int): The interval the task is run in days. + name (str): The name of the task. + scheduleRandomized (bool): Whether the task schedule is randomized. + title (str): The title of the task. + """ + TAG = 'ButlerTask' + + def _loadData(self, data): + self._data = data + self.description = data.attrib.get('description') + self.enabled = utils.cast(bool, data.attrib.get('enabled')) + self.interval = utils.cast(int, data.attrib.get('interval')) + self.name = data.attrib.get('name') + self.scheduleRandomized = utils.cast(bool, data.attrib.get('scheduleRandomized')) + self.title = data.attrib.get('title') diff --git a/lib/plexapi/settings.py b/lib/plexapi/settings.py index 81fdf9b1..e0adba00 100644 --- a/lib/plexapi/settings.py +++ b/lib/plexapi/settings.py @@ -71,7 +71,7 @@ class Settings(PlexObject): return self.groups().get(group, []) def save(self): - """ Save any outstanding settnig changes to the :class:`~plexapi.server.PlexServer`. This + """ Save any outstanding setting changes to the :class:`~plexapi.server.PlexServer`. This performs a full reload() of Settings after complete. """ params = {} @@ -100,7 +100,7 @@ class Setting(PlexObject): hidden (bool): True if this is a hidden setting. advanced (bool): True if this is an advanced setting. group (str): Group name this setting is categorized as. - enumValues (list,dict): List or dictionary of valis values for this setting. + enumValues (list,dict): List or dictionary of valid values for this setting. """ _bool_cast = lambda x: bool(x == 'true' or x == '1') _bool_str = lambda x: str(x).lower() @@ -143,7 +143,7 @@ class Setting(PlexObject): return enumstr.split('|') def set(self, value): - """ Set a new value for this setitng. NOTE: You must call plex.settings.save() for before + """ Set a new value for this setting. NOTE: You must call plex.settings.save() for before any changes to setting values are persisted to the :class:`~plexapi.server.PlexServer`. """ # check a few things up front diff --git a/lib/plexapi/sonos.py b/lib/plexapi/sonos.py index 3bdfc1f2..35956b99 100644 --- a/lib/plexapi/sonos.py +++ b/lib/plexapi/sonos.py @@ -14,7 +14,7 @@ class PlexSonosClient(PlexClient): speakers linked to your Plex account. It also requires remote access to be working properly. - More details on the Sonos integration are avaialble here: + More details on the Sonos integration are available here: https://support.plex.tv/articles/218237558-requirements-for-using-plex-for-sonos/ The Sonos API emulates the Plex player control API closely: @@ -38,7 +38,7 @@ class PlexSonosClient(PlexClient): server (:class:`~plexapi.server.PlexServer`): Server this client is connected to. session (:class:`~requests.Session`): Session object used for connection. title (str): Name of this Sonos speaker. - token (str): X-Plex-Token used for authenication + token (str): X-Plex-Token used for authentication _baseurl (str): Address of public Plex Sonos API endpoint. _commandId (int): Counter for commands sent to Plex API. _token (str): Token associated with linked Plex account. diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index 92d64299..9c43b23a 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -9,6 +9,7 @@ import time import unicodedata import warnings import zipfile +from collections import deque from datetime import datetime from getpass import getpass from threading import Event, Thread @@ -55,7 +56,7 @@ class SecretsFilter(logging.Filter): def registerPlexObject(cls): """ Registry of library types we may come across when parsing XML. This allows us to - define a few helper functions to dynamically convery the XML into objects. See + define a few helper functions to dynamically convert the XML into objects. See buildItem() below for an example. """ etype = getattr(cls, 'STREAMTYPE', getattr(cls, 'TAGTYPE', cls.TYPE)) @@ -72,7 +73,7 @@ def cast(func, value): only support str, int, float, bool. Should be extended if needed. Parameters: - func (func): Calback function to used cast to type (int, bool, float). + func (func): Callback function to used cast to type (int, bool, float). value (any): value to be cast and returned. """ if value is not None: @@ -114,7 +115,7 @@ def lowerFirst(s): def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover - """ Returns the value at the specified attrstr location within a nexted tree of + """ Returns the value at the specified attrstr location within a nested tree of dicts, lists, tuples, functions, classes, etc. The lookup is done recursively for each key in attrstr (split by by the delimiter) This function is heavily influenced by the lookups used in Django templates. @@ -194,7 +195,7 @@ def threaded(callback, listargs): args += [results, len(results)] results.append(None) threads.append(Thread(target=callback, args=args, kwargs=dict(job_is_done_event=job_is_done_event))) - threads[-1].setDaemon(True) + threads[-1].daemon = True threads[-1].start() while not job_is_done_event.is_set(): if all(not t.is_alive() for t in threads): @@ -304,7 +305,7 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4 filename (str): Filename of the downloaded file, default None. savepath (str): Defaults to current working dir. chunksize (int): What chunksize read/write at the time. - mocked (bool): Helper to do evertything except write the file. + mocked (bool): Helper to do everything except write the file. unpack (bool): Unpack the zip file. showstatus(bool): Display a progressbar. @@ -361,40 +362,6 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4 return fullpath -def tag_singular(tag): - if tag == 'countries': - return 'country' - elif tag == 'similar': - return 'similar' - else: - return tag[:-1] - - -def tag_plural(tag): - if tag == 'country': - return 'countries' - elif tag == 'similar': - return 'similar' - else: - return tag + 's' - - -def tag_helper(tag, items, locked=True, remove=False): - """ Simple tag helper for editing a object. """ - if not isinstance(items, list): - items = [items] - data = {} - if not remove: - for i, item in enumerate(items): - tagname = '%s[%s].tag.tag' % (tag, i) - data[tagname] = item - if remove: - tagname = '%s[].tag.tag-' % tag - data[tagname] = ','.join(items) - data['%s.locked' % tag] = 1 if locked else 0 - return data - - def getMyPlexAccount(opts=None): # pragma: no cover """ Helper function tries to get a MyPlex Account instance by checking the the following locations for a username and password. This is @@ -485,7 +452,7 @@ def getAgentIdentifier(section, agent): if agent in identifiers: return ag.identifier agents += identifiers - raise NotFound('Couldnt find "%s" in agents list (%s)' % + raise NotFound('Could not find "%s" in agents list (%s)' % (agent, ', '.join(agents))) @@ -506,3 +473,15 @@ def deprecated(message, stacklevel=2): return func(*args, **kwargs) return wrapper return decorator + + +def iterXMLBFS(root, tag=None): + """ Iterate through an XML tree using a breadth-first search. + If tag is specified, only return nodes with that tag. + """ + queue = deque([root]) + while queue: + node = queue.popleft() + if tag is None or node.tag == tag: + yield node + queue.extend(list(node)) diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 4049d6c5..1604fef3 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -2,12 +2,17 @@ import os from urllib.parse import quote_plus, urlencode -from plexapi import library, media, utils +from plexapi import media, utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest -from plexapi.mixins import AdvancedSettingsMixin, ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin -from plexapi.mixins import RatingMixin, SplitMergeMixin, UnmatchMatchMixin -from plexapi.mixins import CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin +from plexapi.mixins import ( + AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, + ArtUrlMixin, ArtMixin, BannerMixin, PosterUrlMixin, PosterMixin, ThemeUrlMixin, ThemeMixin, + ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, + SummaryMixin, TaglineMixin, TitleMixin, + CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin, + WatchlistMixin +) class Video(PlexPartialObject): @@ -35,7 +40,7 @@ class Video(PlexPartialObject): title (str): Name of the movie, show, season, episode, or clip. titleSort (str): Title to use when sorting (defaults to title). type (str): 'movie', 'show', 'season', 'episode', or 'clip'. - updatedAt (datatime): Datetime the item was updated. + updatedAt (datetime): Datetime the item was updated. userRating (float): Rating of the item (0.0 - 10.0) equaling (0 stars - 5 stars). viewCount (int): Count of times the item was played. """ @@ -76,7 +81,7 @@ class Video(PlexPartialObject): return self._server.url(part, includeToken=True) if part else None def markWatched(self): - """ Mark the video as palyed. """ + """ Mark the video as played. """ key = '/:/scrobble?key=%s&identifier=com.plexapp.plugins.library' % self.ratingKey self._server.query(key) @@ -107,6 +112,15 @@ class Video(PlexPartialObject): """ Returns str, default title for a new syncItem. """ return self.title + def audioStreams(self): + """ Returns a list of :class:`~plexapi.media.AudioStream` objects for all MediaParts. """ + streams = [] + + parts = self.iterParts() + for part in parts: + streams += part.audioStreams() + return streams + def subtitleStreams(self): """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """ streams = [] @@ -261,8 +275,15 @@ class Video(PlexPartialObject): @utils.registerPlexObject -class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin, - CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin): +class Movie( + Video, Playable, + AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, + ArtMixin, PosterMixin, ThemeMixin, + ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, + SummaryMixin, TaglineMixin, TitleMixin, + CollectionMixin, CountryMixin, DirectorMixin, GenreMixin, LabelMixin, ProducerMixin, WriterMixin, + WatchlistMixin +): """ Represents a single Movie. Attributes: @@ -280,7 +301,7 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Ratin genres (List<:class:`~plexapi.media.Genre`>): List of genre objects. guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. labels (List<:class:`~plexapi.media.Label`>): List of label objects. - languageOverride (str): Setting that indicates if a languge is used to override metadata + languageOverride (str): Setting that indicates if a language is used to override metadata (eg. en-CA, None = Library default). media (List<:class:`~plexapi.media.Media`>): List of media objects. originallyAvailableAt (datetime): Datetime the movie was released. @@ -293,6 +314,7 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Ratin similar (List<:class:`~plexapi.media.Similar`>): List of Similar objects. studio (str): Studio that created movie (Di Bonaventura Pictures; 21 Laps Entertainment). tagline (str): Movie tag line (Back 2 Work; Who says men can't change?). + theme (str): URL to theme resource (/library/metadata//theme/). useOriginalTitle (int): Setting that indicates if the original title is used for the movie (-1 = Library default, 0 = No, 1 = Yes). viewOffset (int): View offset in milliseconds. @@ -331,6 +353,7 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Ratin self.similar = self.findItems(data, media.Similar) self.studio = data.attrib.get('studio') self.tagline = data.attrib.get('tagline') + self.theme = data.attrib.get('theme') self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.writers = self.findItems(data, media.Writer) @@ -365,20 +388,17 @@ class Movie(Video, Playable, AdvancedSettingsMixin, ArtMixin, PosterMixin, Ratin data = self._server.query(self._details_key) return self.findItems(data, media.Review, rtag='Video') - def extras(self): - """ Returns a list of :class:`~plexapi.video.Extra` objects. """ - data = self._server.query(self._details_key) - return self.findItems(data, Extra, rtag='Extras') - - def hubs(self): - """ Returns a list of :class:`~plexapi.library.Hub` objects. """ - data = self._server.query(self._details_key) - return self.findItems(data, library.Hub, rtag='Related') - @utils.registerPlexObject -class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, RatingMixin, SplitMergeMixin, UnmatchMatchMixin, - CollectionMixin, GenreMixin, LabelMixin): +class Show( + Video, + AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, RatingMixin, + ArtMixin, BannerMixin, PosterMixin, ThemeMixin, + ContentRatingMixin, OriginallyAvailableMixin, OriginalTitleMixin, SortTitleMixin, StudioMixin, + SummaryMixin, TaglineMixin, TitleMixin, + CollectionMixin, GenreMixin, LabelMixin, + WatchlistMixin +): """ Represents a single Show (including all seasons and episodes). Attributes: @@ -407,7 +427,7 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Rat index (int): Plex index number for the show. key (str): API URL (/library/metadata/). labels (List<:class:`~plexapi.media.Label`>): List of label objects. - languageOverride (str): Setting that indicates if a languge is used to override metadata + languageOverride (str): Setting that indicates if a language is used to override metadata (eg. en-CA, None = Library default). leafCount (int): Number of items in the show view. locations (List): List of folder paths where the show is found on disk. @@ -483,11 +503,6 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Rat """ Returns True if the show is fully watched. """ return bool(self.viewedLeafCount == self.leafCount) - def hubs(self): - """ Returns a list of :class:`~plexapi.library.Hub` objects. """ - data = self._server.query(self._details_key) - return self.findItems(data, library.Hub, rtag='Related') - def onDeck(self): """ Returns show's On Deck :class:`~plexapi.video.Video` object or `None`. If show is unwatched, return will likely be the first episode. @@ -574,7 +589,13 @@ class Show(Video, AdvancedSettingsMixin, ArtMixin, BannerMixin, PosterMixin, Rat @utils.registerPlexObject -class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin): +class Season( + Video, + ExtrasMixin, RatingMixin, + ArtMixin, PosterMixin, ThemeUrlMixin, + SummaryMixin, TitleMixin, + CollectionMixin, LabelMixin +): """ Represents a single Show Season (including all episodes). Attributes: @@ -584,6 +605,7 @@ class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin): guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. index (int): Season number. key (str): API URL (/library/metadata/). + labels (List<:class:`~plexapi.media.Label`>): List of label objects. leafCount (int): Number of items in the season view. parentGuid (str): Plex GUID for the show (plex://show/5d9c086fe9d5a1001f4d9fe6). parentIndex (int): Plex index number for the show. @@ -607,6 +629,7 @@ class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin): self.guids = self.findItems(data, media.Guid) self.index = utils.cast(int, data.attrib.get('index')) self.key = self.key.replace('/children', '') # FIX_BUG_50 + self.labels = self.findItems(data, media.Label) self.leafCount = utils.cast(int, data.attrib.get('leafCount')) self.parentGuid = data.attrib.get('parentGuid') self.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) @@ -709,8 +732,13 @@ class Season(Video, ArtMixin, PosterMixin, RatingMixin, CollectionMixin): @utils.registerPlexObject -class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin, - CollectionMixin, DirectorMixin, WriterMixin): +class Episode( + Video, Playable, + ExtrasMixin, RatingMixin, + ArtMixin, PosterMixin, ThemeUrlMixin, + ContentRatingMixin, OriginallyAvailableMixin, SortTitleMixin, SummaryMixin, TitleMixin, + CollectionMixin, DirectorMixin, LabelMixin, WriterMixin +): """ Represents a single Shows Episode. Attributes: @@ -733,6 +761,7 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin, grandparentTitle (str): Name of the show for the episode. guids (List<:class:`~plexapi.media.Guid`>): List of guid objects. index (int): Episode number. + labels (List<:class:`~plexapi.media.Label`>): List of label objects. markers (List<:class:`~plexapi.media.Marker`>): List of marker objects. media (List<:class:`~plexapi.media.Media`>): List of media objects. originallyAvailableAt (datetime): Datetime the episode was released. @@ -777,6 +806,7 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin, self.grandparentTitle = data.attrib.get('grandparentTitle') self.guids = self.findItems(data, media.Guid) self.index = utils.cast(int, data.attrib.get('index')) + self.labels = self.findItems(data, media.Label) self.markers = self.findItems(data, media.Marker) self.media = self.findItems(data, media.Media) self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') @@ -879,7 +909,10 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin, @utils.registerPlexObject -class Clip(Video, Playable, ArtUrlMixin, PosterUrlMixin): +class Clip( + Video, Playable, + ArtUrlMixin, PosterUrlMixin +): """ Represents a single Clip. Attributes: diff --git a/requirements.txt b/requirements.txt index b215a9f9..d9f7d751 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ MarkupSafe==2.1.1 musicbrainzngs==0.7.1 packaging==21.3 paho-mqtt==1.6.1 -plexapi==4.9.2 +plexapi==4.11.0 portend==3.1.0 profilehooks==1.12.0 PyJWT==2.4.0