diff --git a/lib/plexapi/audio.py b/lib/plexapi/audio.py index 8f84f3be..686073a3 100644 --- a/lib/plexapi/audio.py +++ b/lib/plexapi/audio.py @@ -193,6 +193,7 @@ class Artist( 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/). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. """ TAG = 'Directory' TYPE = 'artist' @@ -213,6 +214,7 @@ class Artist( self.similar = self.findItems(data, media.Similar) self.styles = self.findItems(data, media.Style) self.theme = data.attrib.get('theme') + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) def __iter__(self): for album in self.albums(): @@ -281,6 +283,21 @@ class Artist( filepaths += track.download(_savepath, keep_original_name, **kwargs) return filepaths + def popularTracks(self): + """ Returns a list of :class:`~plexapi.audio.Track` popular tracks by the artist. """ + filters = { + 'album.subformat!': 'Compilation,Live', + 'artist.id': self.ratingKey, + 'group': 'title', + 'ratingCount>>': 0, + } + return self.section().search( + libtype='track', + filters=filters, + sort='ratingCount:desc', + limit=100 + ) + def station(self): """ Returns a :class:`~plexapi.playlist.Playlist` artist radio station or `None`. """ key = f'{self.key}?includeStations=1' @@ -325,6 +342,7 @@ class Album( studio (str): Studio that released the album. styles (List<:class:`~plexapi.media.Style`>): List of style objects. subformats (List<:class:`~plexapi.media.Subformat`>): List of subformat objects. + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. viewedLeafCount (int): Number of items marked as played in the album view. year (int): Year the album was released. """ @@ -354,6 +372,7 @@ class Album( self.studio = data.attrib.get('studio') self.styles = self.findItems(data, media.Style) self.subformats = self.findItems(data, media.Subformat) + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) diff --git a/lib/plexapi/base.py b/lib/plexapi/base.py index 26b103b9..a7fa82ee 100644 --- a/lib/plexapi/base.py +++ b/lib/plexapi/base.py @@ -3,7 +3,7 @@ import re from typing import TYPE_CHECKING, Generic, Iterable, List, Optional, TypeVar, Union import weakref from functools import cached_property -from urllib.parse import urlencode +from urllib.parse import parse_qsl, urlencode, urlparse from xml.etree import ElementTree from xml.etree.ElementTree import Element @@ -391,10 +391,9 @@ class PlexObject: Parameters: key (string, optional): Override the key to reload. - **kwargs (dict): A dictionary of XML include parameters to exclude or override. - All parameters are included by default with the option to override each parameter - or disable each parameter individually by setting it to False or 0. + **kwargs (dict): A dictionary of XML include parameters to include/exclude or override. See :class:`~plexapi.base.PlexPartialObject` for all the available include parameters. + Set parameter to True to include and False to exclude. Example: @@ -402,20 +401,28 @@ class PlexObject: from plexapi.server import PlexServer plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx') - movie = plex.library.section('Movies').get('Cars') - # Partial reload of the movie without the `checkFiles` parameter. - # Excluding `checkFiles` will prevent the Plex server from reading the - # file to check if the file still exists and is accessible. - # The movie object will remain as a partial object. - movie.reload(checkFiles=False) + # Search results are partial objects. + movie = plex.library.section('Movies').get('Cars') movie.isPartialObject() # Returns True - # Full reload of the movie with all include parameters. + # Partial reload of the movie without a default include parameter. + # The movie object will remain as a partial object. + movie.reload(includeMarkers=False) + movie.isPartialObject() # Returns True + + # Full reload of the movie with all default include parameters. # The movie object will be a full object. movie.reload() movie.isFullObject() # Returns True + # Full reload of the movie with all default and extra include parameter. + # Including `checkFiles` will tell the Plex server to check if the file + # still exists and is accessible. + # The movie object will be a full object. + movie.reload(checkFiles=True) + movie.isFullObject() # Returns True + """ return self._reload(key=key, **kwargs) @@ -505,25 +512,25 @@ class PlexPartialObject(PlexObject): automatically and update itself. """ _INCLUDES = { - 'checkFiles': 1, - 'includeAllConcerts': 1, + 'checkFiles': 0, + 'includeAllConcerts': 0, 'includeBandwidths': 1, 'includeChapters': 1, - 'includeChildren': 1, - 'includeConcerts': 1, - 'includeExternalMedia': 1, - 'includeExtras': 1, + 'includeChildren': 0, + 'includeConcerts': 0, + 'includeExternalMedia': 0, + 'includeExtras': 0, 'includeFields': 'thumbBlurHash,artBlurHash', 'includeGeolocation': 1, 'includeLoudnessRamps': 1, 'includeMarkers': 1, - 'includeOnDeck': 1, - 'includePopularLeaves': 1, - 'includePreferences': 1, - 'includeRelated': 1, - 'includeRelatedCount': 1, - 'includeReviews': 1, - 'includeStations': 1, + 'includeOnDeck': 0, + 'includePopularLeaves': 0, + 'includePreferences': 0, + 'includeRelated': 0, + 'includeRelatedCount': 0, + 'includeReviews': 0, + 'includeStations': 0, } _EXCLUDES = { 'excludeElements': ( @@ -592,7 +599,11 @@ class PlexPartialObject(PlexObject): search result for a movie often only contain a portion of the attributes a full object (main url) for that movie would contain. """ - return not self.key or (self._details_key or self.key) == self._initpath + parsed_key = urlparse(self._details_key or self.key) + parsed_initpath = urlparse(self._initpath) + query_key = set(parse_qsl(parsed_key.query)) + query_init = set(parse_qsl(parsed_initpath.query)) + return not self.key or (parsed_key.path == parsed_initpath.path and query_key <= query_init) def isPartialObject(self): """ Returns True if this is not a full object. """ diff --git a/lib/plexapi/client.py b/lib/plexapi/client.py index 76513e79..3d89e3dc 100644 --- a/lib/plexapi/client.py +++ b/lib/plexapi/client.py @@ -197,7 +197,7 @@ class PlexClient(PlexObject): raise NotFound(message) else: raise BadRequest(message) - data = response.text.encode('utf8') + data = utils.cleanXMLString(response.text).encode('utf8') return ElementTree.fromstring(data) if data.strip() else None def sendCommand(self, command, proxy=None, **params): diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index d71ddf2f..1c3ba3f7 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -60,6 +60,7 @@ class Collection( title (str): Name of the collection. titleSort (str): Title to use when sorting (defaults to title). type (str): 'collection' + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. updatedAt (datetime): Datetime the collection was updated. userRating (float): Rating of the collection (0.0 - 10.0) equaling (0 stars - 5 stars). """ @@ -102,6 +103,7 @@ class Collection( self.title = data.attrib.get('title') self.titleSort = data.attrib.get('titleSort', self.title) self.type = data.attrib.get('type') + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.userRating = utils.cast(float, data.attrib.get('userRating')) self._items = None # cache for self.items diff --git a/lib/plexapi/const.py b/lib/plexapi/const.py index b0fe7e7d..130555ad 100644 --- a/lib/plexapi/const.py +++ b/lib/plexapi/const.py @@ -4,6 +4,6 @@ # Library version MAJOR_VERSION = 4 MINOR_VERSION = 15 -PATCH_VERSION = 15 +PATCH_VERSION = 16 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index f1bf5375..6913b829 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -2823,7 +2823,8 @@ class FilteringType(PlexObject): additionalFields.extend([ ('duration', 'integer', 'Duration'), ('viewOffset', 'integer', 'View Offset'), - ('label', 'tag', 'Label') + ('label', 'tag', 'Label'), + ('ratingCount', 'integer', 'Rating Count'), ]) elif self.type == 'collection': additionalFields.extend([ diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 56126dcb..2f76d722 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -106,12 +106,16 @@ class MediaPart(PlexObject): Attributes: TAG (str): 'Part' accessible (bool): True if the file is accessible. + Requires reloading the media with ``checkFiles=True``. + Refer to :func:`~plexapi.base.PlexObject.reload`. audioProfile (str): The audio profile of the file. container (str): The container type of the file (ex: avi). decision (str): Unknown. deepAnalysisVersion (int): The Plex deep analysis version for the file. duration (int): The duration of the file in milliseconds. exists (bool): True if the file exists. + Requires reloading the media with ``checkFiles=True``. + Refer to :func:`~plexapi.base.PlexObject.reload`. file (str): The path to this file on disk (ex: /media/Movies/Cars (2006)/Cars (2006).mkv) has64bitOffsets (bool): True if the file has 64 bit offsets. hasThumbnail (bool): True if the file (track) has an embedded thumbnail. @@ -999,6 +1003,28 @@ class Review(PlexObject): self.text = data.attrib.get('text') +@utils.registerPlexObject +class UltraBlurColors(PlexObject): + """ Represents a single UltraBlurColors media tag. + + Attributes: + TAG (str): 'UltraBlurColors' + bottomLeft (str): The bottom left hex color. + bottomRight (str): The bottom right hex color. + topLeft (str): The top left hex color. + topRight (str): The top right hex color. + """ + TAG = 'UltraBlurColors' + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + self._data = data + self.bottomLeft = data.attrib.get('bottomLeft') + self.bottomRight = data.attrib.get('bottomRight') + self.topLeft = data.attrib.get('topLeft') + self.topRight = data.attrib.get('topRight') + + class BaseResource(PlexObject): """ Base class for all Art, Poster, and Theme objects. diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index 8571ba63..bdf4607e 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -14,8 +14,8 @@ class AdvancedSettingsMixin: def preferences(self): """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ - data = self._server.query(self._details_key) - return self.findItems(data, settings.Preferences, rtag='Preferences') + key = f'{self.key}?includePreferences=1' + return self.fetchItems(key, cls=settings.Preferences, rtag='Preferences') def preference(self, pref): """ Returns a :class:`~plexapi.settings.Preferences` object for the specified pref. @@ -240,8 +240,7 @@ class UnmatchMatchMixin: params['agent'] = utils.getAgentIdentifier(self.section(), agent) key = key + '?' + urlencode(params) - data = self._server.query(key, method=self._server._session.get) - return self.findItems(data, initpath=key) + return self.fetchItems(key, cls=media.SearchResult) def fixMatch(self, searchResult=None, auto=False, agent=None): """ Use match result to update show metadata. @@ -278,8 +277,8 @@ class ExtrasMixin: 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') + key = f'{self.key}/extras' + return self.fetchItems(key, cls=Extra) class HubsMixin: @@ -289,8 +288,7 @@ class HubsMixin: """ Returns a list of :class:`~plexapi.library.Hub` objects. """ from plexapi.library import Hub key = f'{self.key}/related' - data = self._server.query(key) - return self.findItems(data, Hub) + return self.fetchItems(key, cls=Hub) class PlayedUnplayedMixin: diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index bc40583e..24e32e6b 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -250,7 +250,7 @@ class MyPlexAccount(PlexObject): return response.json() elif 'text/plain' in response.headers.get('Content-Type', ''): return response.text.strip() - data = response.text.encode('utf8') + data = utils.cleanXMLString(response.text).encode('utf8') return ElementTree.fromstring(data) if data.strip() else None def ping(self): diff --git a/lib/plexapi/server.py b/lib/plexapi/server.py index f39a423f..8cd110d8 100644 --- a/lib/plexapi/server.py +++ b/lib/plexapi/server.py @@ -768,7 +768,7 @@ class PlexServer(PlexObject): raise NotFound(message) else: raise BadRequest(message) - data = response.text.encode('utf8') + data = utils.cleanXMLString(response.text).encode('utf8') return ElementTree.fromstring(data) if data.strip() else None def search(self, query, mediatype=None, limit=None, sectionId=None): diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index bb128532..549afc5b 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -6,6 +6,7 @@ import logging import os import re import string +import sys import time import unicodedata import warnings @@ -673,3 +674,45 @@ def openOrRead(file): def sha1hash(guid): """ Return the SHA1 hash of a guid. """ return sha1(guid.encode('utf-8')).hexdigest() + + +# https://stackoverflow.com/a/64570125 +_illegal_XML_characters = [ + (0x00, 0x08), + (0x0B, 0x0C), + (0x0E, 0x1F), + (0x7F, 0x84), + (0x86, 0x9F), + (0xFDD0, 0xFDDF), + (0xFFFE, 0xFFFF), +] +if sys.maxunicode >= 0x10000: # not narrow build + _illegal_XML_characters.extend( + [ + (0x1FFFE, 0x1FFFF), + (0x2FFFE, 0x2FFFF), + (0x3FFFE, 0x3FFFF), + (0x4FFFE, 0x4FFFF), + (0x5FFFE, 0x5FFFF), + (0x6FFFE, 0x6FFFF), + (0x7FFFE, 0x7FFFF), + (0x8FFFE, 0x8FFFF), + (0x9FFFE, 0x9FFFF), + (0xAFFFE, 0xAFFFF), + (0xBFFFE, 0xBFFFF), + (0xCFFFE, 0xCFFFF), + (0xDFFFE, 0xDFFFF), + (0xEFFFE, 0xEFFFF), + (0xFFFFE, 0xFFFFF), + (0x10FFFE, 0x10FFFF), + ] + ) +_illegal_XML_ranges = [ + fr'{chr(low)}-{chr(high)}' + for (low, high) in _illegal_XML_characters +] +_illegal_XML_re = re.compile(fr'[{"".join(_illegal_XML_ranges)}]') + + +def cleanXMLString(s): + return _illegal_XML_re.sub('', s) diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 609f57f6..15755415 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -375,6 +375,7 @@ class Movie( 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/). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. 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. @@ -420,6 +421,7 @@ class Movie( self.studio = data.attrib.get('studio') self.tagline = data.attrib.get('tagline') self.theme = data.attrib.get('theme') + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) 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) @@ -456,8 +458,8 @@ class Movie( def reviews(self): """ Returns a list of :class:`~plexapi.media.Review` objects. """ - data = self._server.query(self._details_key) - return self.findItems(data, media.Review, rtag='Video') + key = f'{self.key}?includeReviews=1' + return self.fetchItems(key, cls=media.Review, rtag='Video') def editions(self): """ Returns a list of :class:`~plexapi.video.Movie` objects @@ -543,6 +545,7 @@ class Show( (-1 = Account default, 0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled). tagline (str): Show tag line. theme (str): URL to theme resource (/library/metadata//theme/). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. useOriginalTitle (int): Setting that indicates if the original title is used for the show (-1 = Library default, 0 = No, 1 = Yes). viewedLeafCount (int): Number of items marked as played in the show view. @@ -592,6 +595,7 @@ class Show( self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1')) self.tagline = data.attrib.get('tagline') self.theme = data.attrib.get('theme') + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) self.useOriginalTitle = utils.cast(int, data.attrib.get('useOriginalTitle', '-1')) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) @@ -614,8 +618,8 @@ class Show( """ Returns show's On Deck :class:`~plexapi.video.Video` object or `None`. If show is unwatched, return will likely be the first episode. """ - data = self._server.query(self._details_key) - return next(iter(self.findItems(data, rtag='OnDeck')), None) + key = f'{self.key}?includeOnDeck=1' + return next(iter(self.fetchItems(key, cls=Episode, rtag='OnDeck')), None) def season(self, title=None, season=None): """ Returns the season with the specified title or number. @@ -735,6 +739,7 @@ class Season( subtitleLanguage (str): Setting that indicates the preferred subtitle language. subtitleMode (int): Setting that indicates the auto-select subtitle mode. (-1 = Series default, 0 = Manually selected, 1 = Shown with foreign audio, 2 = Always enabled). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. viewedLeafCount (int): Number of items marked as played in the season view. year (int): Year the season was released. """ @@ -766,6 +771,7 @@ class Season( self.ratings = self.findItems(data, media.Rating) self.subtitleLanguage = data.attrib.get('subtitleLanguage', '') self.subtitleMode = utils.cast(int, data.attrib.get('subtitleMode', '-1')) + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) @@ -796,8 +802,8 @@ class Season( """ Returns season's On Deck :class:`~plexapi.video.Video` object or `None`. Will only return a match if the show's On Deck episode is in this season. """ - data = self._server.query(self._details_key) - return next(iter(self.findItems(data, rtag='OnDeck')), None) + key = f'{self.key}?includeOnDeck=1' + return next(iter(self.fetchItems(key, cls=Episode, rtag='OnDeck')), None) def episode(self, title=None, episode=None): """ Returns the episode with the given title or number. @@ -914,6 +920,7 @@ class Episode( skipParent (bool): True if the show's seasons are set to hidden. sourceURI (str): Remote server URI (server:///com.plexapp.plugins.library) (remote playlist item only). + ultraBlurColors (:class:`~plexapi.media.UltraBlurColors`): Ultra blur color object. viewOffset (int): View offset in milliseconds. writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. year (int): Year the episode was released. @@ -958,6 +965,7 @@ class Episode( self.roles = self.findItems(data, media.Role) self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) self.sourceURI = data.attrib.get('source') # remote playlist item + self.ultraBlurColors = self.findItem(data, media.UltraBlurColors) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.writers = self.findItems(data, media.Writer) self.year = utils.cast(int, data.attrib.get('year')) diff --git a/requirements.txt b/requirements.txt index d0ee1941..d37487f1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,7 @@ musicbrainzngs==0.7.1 packaging==24.1 paho-mqtt==2.1.0 platformdirs==4.2.2 -plexapi==4.15.15 +plexapi==4.15.16 portend==3.2.0 profilehooks==1.12.0 PyJWT==2.9.0