From 0806788703030a139bb37ff704f1c382a918310a Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Fri, 4 Apr 2025 01:47:36 +0000 Subject: [PATCH 01/33] feat: Implemented a caching mecahnism for PlexObject classes - Cached properties are defined using a `cached_data_property` decorator - Property caches are automatically invalidated whenever the `_data` atribute is changed - The implementation uses a metaclass to collect and track all cached properties across class inheritances --- plexapi/base.py | 56 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 675ac5d98..4805503a3 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -39,7 +39,42 @@ } -class PlexObject: +class cached_data_property(cached_property): + """Caching for PlexObject data properties. + + This decorator creates properties that cache their values with + automatic invalidation on data changes. + """ + + def __set_name__(self, owner, name): + """Register the annotated property in the parent class's _cached_data_properties set.""" + super().__set_name__(owner, name) + if not hasattr(owner, '_cached_data_properties'): + owner._cached_data_properties = set() + owner._cached_data_properties.add(name) + + +class PlexObjectMeta(type): + """Metaclass for PlexObject to handle cached_data_properties.""" + def __new__(mcs, name, bases, attrs): + cached_data_props = set() + + # Merge all _cached_data_properties from parent classes + for base in bases: + if hasattr(base, '_cached_data_properties'): + cached_data_props.update(base._cached_data_properties) + + # Find all properties annotated with cached_data_property in the current class + for attr_name, attr_value in attrs.items(): + if isinstance(attr_value, cached_data_property): + cached_data_props.add(attr_name) + + attrs['_cached_data_properties'] = cached_data_props + + return super().__new__(mcs, name, bases, attrs) + + +class PlexObject(metaclass=PlexObjectMeta): """ Base class for all Plex objects. Parameters: @@ -54,7 +89,7 @@ class PlexObject: def __init__(self, server, data, initpath=None, parent=None): self._server = server - self._data = data + PlexObject._loadData(self, data) self._initpath = initpath or self.key self._parent = weakref.ref(parent) if parent is not None else None self._details_key = None @@ -497,8 +532,23 @@ def _castAttrValue(self, op, query, value): return float(value) return value + def _invalidateCachedProperties(self): + """Invalidate all cached data property values.""" + cached_props = getattr(self.__class__, '_cached_data_properties', set()) + + for prop_name in cached_props: + cache_name = f"_{prop_name}" + if cache_name in self.__dict__: + del self.__dict__[cache_name] + def _loadData(self, data): - raise NotImplementedError('Abstract method not implemented.') + """Load attribute values from Plex XML response and invalidate cached properties.""" + old_data_id = id(getattr(self, '_data', None)) + self._data = data + + # If the data's object ID has changed, invalidate cached properties + if id(data) != old_data_id: + self._invalidateCachedProperties() @property def _searchType(self): From 9fe844b2c1fe41914511d610d1532720d8105342 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Fri, 4 Apr 2025 01:54:06 +0000 Subject: [PATCH 02/33] perf: Cache all data attributes that are computation heavy These attributes include those that call `findItems` and `listAttrs` --- plexapi/audio.py | 129 +++++++++++++++++++------ plexapi/collection.py | 19 +++- plexapi/library.py | 109 +++++++++++++++------ plexapi/media.py | 14 ++- plexapi/myplex.py | 49 +++++++--- plexapi/photo.py | 32 ++++-- plexapi/playlist.py | 7 +- plexapi/playqueue.py | 9 +- plexapi/server.py | 18 ++-- plexapi/video.py | 220 +++++++++++++++++++++++++++++++++--------- 10 files changed, 460 insertions(+), 146 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 05d38a9c7..ca43785f5 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional, TypeVar from plexapi import media, utils -from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession +from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession, cached_data_property from plexapi.exceptions import BadRequest from plexapi.mixins import ( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, @@ -59,14 +59,12 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexPartialObject._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') self.distance = utils.cast(float, data.attrib.get('distance')) - self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') - self.images = self.findItems(data, media.Image) self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key', '') self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) @@ -75,7 +73,6 @@ def _loadData(self, data): self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.listType = 'audio' - self.moods = self.findItems(data, media.Mood) self.musicAnalysisVersion = utils.cast(int, data.attrib.get('musicAnalysisVersion')) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.summary = data.attrib.get('summary') @@ -88,6 +85,18 @@ def _loadData(self, data): self.userRating = utils.cast(float, data.attrib.get('userRating')) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) + @cached_data_property + def fields(self): + return self.findItems(self._data, media.Field) + + @cached_data_property + def images(self): + return self.findItems(self._data, media.Image) + + @cached_data_property + def moods(self): + return self.findItems(self._data, media.Mood) + def url(self, part): """ Returns the full URL for the audio item. Typically used for getting a specific track. """ return self._server.url(part, includeToken=True) if part else None @@ -205,18 +214,45 @@ def _loadData(self, data): Audio._loadData(self, data) self.albumSort = utils.cast(int, data.attrib.get('albumSort', '-1')) self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) - self.collections = self.findItems(data, media.Collection) - self.countries = self.findItems(data, media.Country) - self.genres = self.findItems(data, media.Genre) - self.guids = self.findItems(data, media.Guid) 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.rating = utils.cast(float, data.attrib.get('rating')) - 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) + + @cached_data_property + def collections(self): + return self.findItems(self._data, media.Collection) + + @cached_data_property + def countries(self): + return self.findItems(self._data, media.Country) + + @cached_data_property + def genres(self): + return self.findItems(self._data, media.Genre) + + @cached_data_property + def guids(self): + return self.findItems(self._data, media.Guid) + + @cached_data_property + def labels(self): + return self.findItems(self._data, media.Label) + + @cached_data_property + def locations(self): + return self.listAttrs(self._data, 'path', etag='Location') + + @cached_data_property + def similar(self): + return self.findItems(self._data, media.Similar) + + @cached_data_property + def styles(self): + return self.findItems(self._data, media.Style) + + @cached_data_property + def ultraBlurColors(self): + return self.findItem(self._data, media.UltraBlurColors) def __iter__(self): for album in self.albums(): @@ -355,12 +391,7 @@ def _loadData(self, data): """ Load attribute values from Plex XML response. """ Audio._loadData(self, data) self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) - self.collections = self.findItems(data, media.Collection) - self.formats = self.findItems(data, media.Format) - self.genres = self.findItems(data, media.Genre) - self.guids = self.findItems(data, media.Guid) 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.loudnessAnalysisVersion = utils.cast(int, data.attrib.get('loudnessAnalysisVersion')) self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') @@ -372,12 +403,38 @@ def _loadData(self, data): self.parentTitle = data.attrib.get('parentTitle') self.rating = utils.cast(float, data.attrib.get('rating')) 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')) + @cached_data_property + def collections(self): + return self.findItems(self._data, media.Collection) + + @cached_data_property + def formats(self): + return self.findItems(self._data, media.Format) + + @cached_data_property + def genres(self): + return self.findItems(self._data, media.Genre) + + @cached_data_property + def guids(self): + return self.findItems(self._data, media.Guid) + + @cached_data_property + def labels(self): + return self.findItems(self._data, media.Label) + + @cached_data_property + def styles(self): + return self.findItems(self._data, media.Style) + + @cached_data_property + def subformats(self): + return self.findItems(self._data, media.Subformat) + def __iter__(self): for track in self.tracks(): yield track @@ -495,11 +552,8 @@ def _loadData(self, data): Audio._loadData(self, data) Playable._loadData(self, data) self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) - self.chapters = self.findItems(data, media.Chapter) self.chapterSource = data.attrib.get('chapterSource') - self.collections = self.findItems(data, media.Collection) self.duration = utils.cast(int, data.attrib.get('duration')) - self.genres = self.findItems(data, media.Genre) self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentGuid = data.attrib.get('grandparentGuid') self.grandparentKey = data.attrib.get('grandparentKey') @@ -507,9 +561,6 @@ def _loadData(self, data): self.grandparentTheme = data.attrib.get('grandparentTheme') self.grandparentThumb = data.attrib.get('grandparentThumb') self.grandparentTitle = data.attrib.get('grandparentTitle') - self.guids = self.findItems(data, media.Guid) - 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 = utils.cast(int, data.attrib.get('parentIndex')) @@ -525,6 +576,30 @@ def _loadData(self, data): self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.year = utils.cast(int, data.attrib.get('year')) + @cached_data_property + def chapters(self): + return self.findItems(self._data, media.Chapter) + + @cached_data_property + def collections(self): + return self.findItems(self._data, media.Collection) + + @cached_data_property + def genres(self): + return self.findItems(self._data, media.Genre) + + @cached_data_property + def guids(self): + return self.findItems(self._data, media.Guid) + + @cached_data_property + def labels(self): + return self.findItems(self._data, media.Label) + + @cached_data_property + def media(self): + return self.findItems(self._data, media.Media) + @property def locations(self): """ This does not exist in plex xml response but is added to have a common diff --git a/plexapi/collection.py b/plexapi/collection.py index 63ea83730..93db6b643 100644 --- a/plexapi/collection.py +++ b/plexapi/collection.py @@ -3,7 +3,7 @@ from urllib.parse import quote_plus from plexapi import media, utils -from plexapi.base import PlexPartialObject +from plexapi.base import PlexPartialObject, cached_data_property from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection, ManagedHub from plexapi.mixins import ( @@ -69,7 +69,7 @@ class Collection( TYPE = 'collection' def _loadData(self, data): - self._data = data + PlexPartialObject._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') @@ -81,12 +81,9 @@ def _loadData(self, data): self.collectionSort = utils.cast(int, data.attrib.get('collectionSort', '0')) self.content = data.attrib.get('content') self.contentRating = data.attrib.get('contentRating') - self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') - self.images = self.findItems(data, media.Image) self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 - self.labels = self.findItems(data, media.Label) self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID')) self.librarySectionKey = data.attrib.get('librarySectionKey') @@ -112,6 +109,18 @@ def _loadData(self, data): self._section = None # cache for self.section self._filters = None # cache for self.filters + @cached_data_property + def fields(self): + return self.findItems(self._data, media.Field) + + @cached_data_property + def images(self): + return self.findItems(self._data, media.Image) + + @cached_data_property + def labels(self): + return self.findItems(self._data, media.Label) + def __len__(self): # pragma: no cover return len(self.items()) diff --git a/plexapi/library.py b/plexapi/library.py index 93801a1d7..b38157b30 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -10,7 +10,7 @@ from urllib.parse import parse_qs, quote_plus, urlencode, urlparse from plexapi import log, media, utils -from plexapi.base import OPERATORS, PlexObject +from plexapi.base import OPERATORS, PlexObject, cached_data_property from plexapi.exceptions import BadRequest, NotFound from plexapi.mixins import ( MovieEditMixins, ShowEditMixins, SeasonEditMixins, EpisodeEditMixins, @@ -2224,7 +2224,6 @@ def _loadData(self, data): self.context = data.attrib.get('context') self.hubKey = data.attrib.get('hubKey') self.hubIdentifier = data.attrib.get('hubIdentifier') - self.items = self.findItems(data) self.key = data.attrib.get('key') self.more = utils.cast(bool, data.attrib.get('more')) self.size = utils.cast(int, data.attrib.get('size')) @@ -2233,6 +2232,10 @@ def _loadData(self, data): self.type = data.attrib.get('type') self._section = None # cache for self.section + @cached_data_property + def items(self): + return self.findItems(self._data) + def __len__(self): return self.size @@ -2279,7 +2282,7 @@ class LibraryMediaTag(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.count = utils.cast(int, data.attrib.get('count')) self.filter = data.attrib.get('filter') self.id = utils.cast(int, data.attrib.get('id')) @@ -2668,22 +2671,25 @@ def __repr__(self): return f"<{':'.join([p for p in [self.__class__.__name__, _type] if p])}>" def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.active = utils.cast(bool, data.attrib.get('active', '0')) - self.fields = self.findItems(data, FilteringField) - self.filters = self.findItems(data, FilteringFilter) self.key = data.attrib.get('key') - self.sorts = self.findItems(data, FilteringSort) self.title = data.attrib.get('title') self.type = data.attrib.get('type') 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() + @cached_data_property + def fields(self): + return self.findItems(self._data, FilteringField) + self._manualFields() + + @cached_data_property + def filters(self): + return self.findItems(self._data, FilteringFilter) + self._manualFilters() + + @cached_data_property + def sorts(self): + return self.findItems(self._data, FilteringSort) + self._manualSorts() def _manualFilters(self): """ Manually add additional filters which are available @@ -2937,9 +2943,12 @@ def __repr__(self): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.type = data.attrib.get('type') - self.operators = self.findItems(data, FilteringOperator) + + @cached_data_property + def operators(self): + return self.findItems(self._data, FilteringOperator) class FilteringOperator(PlexObject): @@ -3268,41 +3277,83 @@ class Common(PlexObject): TAG = 'Common' def _loadData(self, data): - self._data = data - self.collections = self.findItems(data, media.Collection) + PlexObject._loadData(self, data) self.contentRating = data.attrib.get('contentRating') - self.countries = self.findItems(data, media.Country) - self.directors = self.findItems(data, media.Director) self.editionTitle = data.attrib.get('editionTitle') - self.fields = self.findItems(data, media.Field) - self.genres = self.findItems(data, media.Genre) self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) self.grandparentTitle = data.attrib.get('grandparentTitle') self.guid = data.attrib.get('guid') - self.guids = self.findItems(data, media.Guid) self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key') - self.labels = self.findItems(data, media.Label) self.mixedFields = data.attrib.get('mixedFields').split(',') - self.moods = self.findItems(data, media.Mood) self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt')) self.parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self.parentTitle = data.attrib.get('parentTitle') - self.producers = self.findItems(data, media.Producer) self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) - self.ratings = self.findItems(data, media.Rating) - self.roles = self.findItems(data, media.Role) self.studio = data.attrib.get('studio') - self.styles = self.findItems(data, media.Style) self.summary = data.attrib.get('summary') self.tagline = data.attrib.get('tagline') - self.tags = self.findItems(data, media.Tag) self.title = data.attrib.get('title') self.titleSort = data.attrib.get('titleSort') self.type = data.attrib.get('type') - self.writers = self.findItems(data, media.Writer) self.year = utils.cast(int, data.attrib.get('year')) + @cached_data_property + def collections(self): + return self.findItems(self._data, media.Collection) + + @cached_data_property + def countries(self): + return self.findItems(self._data, media.Country) + + @cached_data_property + def directors(self): + return self.findItems(self._data, media.Director) + + @cached_data_property + def fields(self): + return self.findItems(self._data, media.Field) + + @cached_data_property + def genres(self): + return self.findItems(self._data, media.Genre) + + @cached_data_property + def guids(self): + return self.findItems(self._data, media.Guid) + + @cached_data_property + def labels(self): + return self.findItems(self._data, media.Label) + + @cached_data_property + def moods(self): + return self.findItems(self._data, media.Mood) + + @cached_data_property + def producers(self): + return self.findItems(self._data, media.Producer) + + @cached_data_property + def ratings(self): + return self.findItems(self._data, media.Rating) + + @cached_data_property + def roles(self): + return self.findItems(self._data, media.Role) + + @cached_data_property + def styles(self): + return self.findItems(self._data, media.Style) + + @cached_data_property + def tags(self): + return self.findItems(self._data, media.Tag) + + @cached_data_property + def writers(self): + return self.findItems(self._data, media.Writer) + def __repr__(self): return '<%s:%s:%s>' % ( self.__class__.__name__, diff --git a/plexapi/media.py b/plexapi/media.py index 9c6e3115b..bb688974a 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -4,7 +4,7 @@ from urllib.parse import quote_plus from plexapi import log, settings, utils -from plexapi.base import PlexObject +from plexapi.base import PlexObject, cached_data_property from plexapi.exceptions import BadRequest from plexapi.utils import deprecated @@ -51,7 +51,7 @@ class Media(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.aspectRatio = utils.cast(float, data.attrib.get('aspectRatio')) self.audioChannels = utils.cast(int, data.attrib.get('audioChannels')) self.audioCodec = data.attrib.get('audioCodec') @@ -64,7 +64,6 @@ def _loadData(self, data): self.has64bitOffsets = utils.cast(bool, data.attrib.get('has64bitOffsets')) self.hasVoiceActivity = utils.cast(bool, data.attrib.get('hasVoiceActivity', '0')) self.optimizedForStreaming = utils.cast(bool, data.attrib.get('optimizedForStreaming')) - self.parts = self.findItems(data, MediaPart) self.proxyType = utils.cast(int, data.attrib.get('proxyType')) self.selected = utils.cast(bool, data.attrib.get('selected')) self.target = data.attrib.get('target') @@ -87,6 +86,10 @@ def _loadData(self, data): parent = self._parent() self._parentKey = parent.key + @cached_data_property + def parts(self): + return self.findItems(self._data, MediaPart) + @property def isOptimizedVersion(self): """ Returns True if the media is a Plex optimized version. """ @@ -1291,10 +1294,13 @@ def __repr__(self): return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>" def _loadData(self, data): - self.languageCodes = self.listAttrs(data, 'code', etag='Language') self.mediaType = utils.cast(int, data.attrib.get('mediaType')) self.name = data.attrib.get('name') + @cached_data_property + def languageCodes(self): + return self.listAttrs(self._data, 'code', etag='Language') + @property @deprecated('use "languageCodes" instead') def languageCode(self): diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 448a2649a..602413f64 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -10,7 +10,7 @@ from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, X_PLEX_IDENTIFIER, log, logfilter, utils) -from plexapi.base import PlexObject +from plexapi.base import PlexObject, cached_data_property from plexapi.client import PlexClient from plexapi.exceptions import BadRequest, NotFound, Unauthorized, TwoFactorRequired from plexapi.library import LibrarySection @@ -144,7 +144,7 @@ def signout(self): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self._token = logfilter.add_secret(data.attrib.get('authToken')) self._webhooks = [] @@ -185,7 +185,6 @@ def _loadData(self, data): subscription = data.find('subscription') self.subscriptionActive = utils.cast(bool, subscription.attrib.get('active')) self.subscriptionDescription = data.attrib.get('subscriptionDescription') - self.subscriptionFeatures = self.listAttrs(subscription, 'id', rtag='features', etag='feature') self.subscriptionPaymentService = subscription.attrib.get('paymentService') self.subscriptionPlan = subscription.attrib.get('plan') self.subscriptionStatus = subscription.attrib.get('status') @@ -201,12 +200,22 @@ def _loadData(self, data): self.profileDefaultSubtitleAccessibility = utils.cast(int, profile.attrib.get('defaultSubtitleAccessibility')) self.profileDefaultSubtitleForces = utils.cast(int, profile.attrib.get('defaultSubtitleForces')) - self.entitlements = self.listAttrs(data, 'id', rtag='entitlements', etag='entitlement') - self.roles = self.listAttrs(data, 'id', rtag='roles', etag='role') - # TODO: Fetch missing MyPlexAccount services self.services = None + @cached_data_property + def subscriptionFeatures(self): + subscription = self._data.find('subscription') + return self.listAttrs(subscription, 'id', rtag='features', etag='feature') + + @cached_data_property + def entitlements(self): + return self.listAttrs(self._data, 'id', rtag='entitlements', etag='entitlement') + + @cached_data_property + def roles(self): + return self.listAttrs(self._data, 'id', rtag='roles', etag='role') + @property def authenticationToken(self): """ Returns the authentication token for the account. Alias for ``authToken``. """ @@ -1206,7 +1215,7 @@ class MyPlexUser(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.friend = self._initpath == self.key self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload')) self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels')) @@ -1225,10 +1234,13 @@ def _loadData(self, data): self.thumb = data.attrib.get('thumb') self.title = data.attrib.get('title', '') self.username = data.attrib.get('username', '') - self.servers = self.findItems(data, MyPlexServerShare) for server in self.servers: server.accountID = self.id + @cached_data_property + def servers(self): + return self.findItems(self._data, MyPlexServerShare) + def get_token(self, machineIdentifier): try: for item in self._server.query(self._server.FRIENDINVITE.format(machineId=machineIdentifier)): @@ -1283,7 +1295,7 @@ class MyPlexInvite(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) self.email = data.attrib.get('email') self.friend = utils.cast(bool, data.attrib.get('friend')) @@ -1291,12 +1303,15 @@ def _loadData(self, data): self.home = utils.cast(bool, data.attrib.get('home')) self.id = utils.cast(int, data.attrib.get('id')) self.server = utils.cast(bool, data.attrib.get('server')) - self.servers = self.findItems(data, MyPlexServerShare) self.thumb = data.attrib.get('thumb') self.username = data.attrib.get('username', '') for server in self.servers: server.accountID = self.id + @cached_data_property + def servers(self): + return self.findItems(self._data, MyPlexServerShare) + class Section(PlexObject): """ This refers to a shared section. The raw xml for the data presented here @@ -1437,10 +1452,9 @@ class MyPlexResource(PlexObject): DEFAULT_SCHEME_ORDER = ['https', 'http'] def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.accessToken = logfilter.add_secret(data.attrib.get('accessToken')) self.clientIdentifier = data.attrib.get('clientIdentifier') - self.connections = self.findItems(data, ResourceConnection, rtag='connections') self.createdAt = utils.toDatetime(data.attrib.get('createdAt'), "%Y-%m-%dT%H:%M:%SZ") self.device = data.attrib.get('device') self.dnsRebindingProtection = utils.cast(bool, data.attrib.get('dnsRebindingProtection')) @@ -1462,6 +1476,10 @@ def _loadData(self, data): self.sourceTitle = data.attrib.get('sourceTitle') self.synced = utils.cast(bool, data.attrib.get('synced')) + @cached_data_property + def connections(self): + return self.findItems(self._data, ResourceConnection, rtag='connections') + def preferred_connections( self, ssl=None, @@ -1598,7 +1616,7 @@ class MyPlexDevice(PlexObject): key = 'https://plex.tv/devices.xml' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.name = data.attrib.get('name') self.publicAddress = data.attrib.get('publicAddress') self.product = data.attrib.get('product') @@ -1617,7 +1635,10 @@ def _loadData(self, data): self.screenDensity = data.attrib.get('screenDensity') self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) self.lastSeenAt = utils.toDatetime(data.attrib.get('lastSeenAt')) - self.connections = self.listAttrs(data, 'uri', etag='Connection') + + @cached_data_property + def connections(self): + return self.listAttrs(self._data, 'uri', etag='Connection') def connect(self, timeout=None): """ Returns a new :class:`~plexapi.client.PlexClient` or :class:`~plexapi.server.PlexServer` diff --git a/plexapi/photo.py b/plexapi/photo.py index 4347f31a8..e7c7239e8 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -4,7 +4,7 @@ from urllib.parse import quote_plus from plexapi import media, utils, video -from plexapi.base import Playable, PlexPartialObject, PlexSession +from plexapi.base import Playable, PlexPartialObject, PlexSession, cached_data_property from plexapi.exceptions import BadRequest from plexapi.mixins import ( RatingMixin, @@ -56,9 +56,7 @@ def _loadData(self, data): self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.composite = data.attrib.get('composite') - self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') - self.images = self.findItems(data, media.Image) self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key', '').replace('/children', '') # FIX_BUG_50 self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) @@ -75,6 +73,14 @@ def _loadData(self, data): self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.userRating = utils.cast(float, data.attrib.get('userRating')) + @cached_data_property + def fields(self): + return self.findItems(self._data, media.Field) + + @cached_data_property + def images(self): + return self.findItems(self._data, media.Image) + def album(self, title): """ Returns the :class:`~plexapi.photo.Photoalbum` that matches the specified title. @@ -205,9 +211,7 @@ def _loadData(self, data): self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.createdAtAccuracy = data.attrib.get('createdAtAccuracy') self.createdAtTZOffset = utils.cast(int, data.attrib.get('createdAtTZOffset')) - self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') - self.images = self.findItems(data, media.Image) self.index = utils.cast(int, data.attrib.get('index')) self.key = data.attrib.get('key', '') self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) @@ -215,7 +219,6 @@ def _loadData(self, data): self.librarySectionKey = data.attrib.get('librarySectionKey') self.librarySectionTitle = data.attrib.get('librarySectionTitle') self.listType = 'photo' - self.media = self.findItems(data, media.Media) self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.parentGuid = data.attrib.get('parentGuid') self.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) @@ -226,7 +229,6 @@ def _loadData(self, data): self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) self.sourceURI = data.attrib.get('source') # remote playlist item self.summary = data.attrib.get('summary') - self.tags = self.findItems(data, media.Tag) self.thumb = data.attrib.get('thumb') self.title = data.attrib.get('title') self.titleSort = data.attrib.get('titleSort', self.title) @@ -235,6 +237,22 @@ def _loadData(self, data): self.userRating = utils.cast(float, data.attrib.get('userRating')) self.year = utils.cast(int, data.attrib.get('year')) + @cached_data_property + def fields(self): + return self.findItems(self._data, media.Field) + + @cached_data_property + def images(self): + return self.findItems(self._data, media.Image) + + @cached_data_property + def media(self): + return self.findItems(self._data, media.Media) + + @cached_data_property + def tags(self): + return self.findItems(self._data, media.Tag) + def _prettyfilename(self): """ Returns a filename for use in download. """ if self.parentTitle: diff --git a/plexapi/playlist.py b/plexapi/playlist.py index e2c4da635..0662e6165 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -5,7 +5,7 @@ from urllib.parse import quote_plus, unquote from plexapi import media, utils -from plexapi.base import Playable, PlexPartialObject +from plexapi.base import Playable, PlexPartialObject, cached_data_property from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection, MusicSection from plexapi.mixins import SmartFilterMixin, ArtMixin, PosterMixin, PlaylistEditMixins @@ -60,7 +60,6 @@ def _loadData(self, data): self.content = data.attrib.get('content') self.duration = utils.cast(int, data.attrib.get('duration')) self.durationInSeconds = utils.cast(int, data.attrib.get('durationInSeconds')) - self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') self.icon = data.attrib.get('icon') self.key = data.attrib.get('key', '').replace('/items', '') # FIX_BUG_50 @@ -81,6 +80,10 @@ def _loadData(self, data): self._section = None # cache for self.section self._filters = None # cache for self.filters + @cached_data_property + def fields(self): + return self.findItems(self._data, media.Field) + def __len__(self): # pragma: no cover return len(self.items()) diff --git a/plexapi/playqueue.py b/plexapi/playqueue.py index 9835c0dd2..e8874741a 100644 --- a/plexapi/playqueue.py +++ b/plexapi/playqueue.py @@ -2,7 +2,7 @@ from urllib.parse import quote_plus from plexapi import utils -from plexapi.base import PlexObject +from plexapi.base import PlexObject, cached_data_property from plexapi.exceptions import BadRequest @@ -36,7 +36,7 @@ class PlayQueue(PlexObject): TYPE = "playqueue" def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.identifier = data.attrib.get("identifier") self.mediaTagPrefix = data.attrib.get("mediaTagPrefix") self.mediaTagVersion = utils.cast(int, data.attrib.get("mediaTagVersion")) @@ -62,9 +62,12 @@ def _loadData(self, data): ) self.playQueueVersion = utils.cast(int, data.attrib.get("playQueueVersion")) self.size = utils.cast(int, data.attrib.get("size", 0)) - self.items = self.findItems(data) self.selectedItem = self[self.playQueueSelectedItemOffset] + @cached_data_property + def items(self): + return self.findItems(self._data) + def __getitem__(self, key): if not self.items: return None diff --git a/plexapi/server.py b/plexapi/server.py index 8cd110d80..c99d7a408 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -118,7 +118,7 @@ def __init__(self, baseurl=None, token=None, session=None, timeout=None): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload')) self.allowChannelAccess = utils.cast(bool, data.attrib.get('allowChannelAccess')) self.allowMediaDeletion = utils.cast(bool, data.attrib.get('allowMediaDeletion')) @@ -1093,7 +1093,7 @@ class Account(PlexObject): key = '/myplex/account' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.authToken = data.attrib.get('authToken') self.username = data.attrib.get('username') self.mappingState = data.attrib.get('mappingState') @@ -1114,7 +1114,7 @@ class Activity(PlexObject): key = '/activities' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.cancellable = utils.cast(bool, data.attrib.get('cancellable')) self.progress = utils.cast(int, data.attrib.get('progress')) self.title = data.attrib.get('title') @@ -1154,7 +1154,7 @@ class SystemAccount(PlexObject): TAG = 'Account' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.autoSelectAudio = utils.cast(bool, data.attrib.get('autoSelectAudio')) self.defaultAudioLanguage = data.attrib.get('defaultAudioLanguage') self.defaultSubtitleLanguage = data.attrib.get('defaultSubtitleLanguage') @@ -1183,7 +1183,7 @@ class SystemDevice(PlexObject): TAG = 'Device' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.clientIdentifier = data.attrib.get('clientIdentifier') self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) self.id = utils.cast(int, data.attrib.get('id')) @@ -1209,7 +1209,7 @@ class StatisticsBandwidth(PlexObject): TAG = 'StatisticsBandwidth' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.accountID = utils.cast(int, data.attrib.get('accountID')) self.at = utils.toDatetime(data.attrib.get('at')) self.bytes = utils.cast(int, data.attrib.get('bytes')) @@ -1251,7 +1251,7 @@ class StatisticsResources(PlexObject): TAG = 'StatisticsResources' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.at = utils.toDatetime(data.attrib.get('at')) self.hostCpuUtilization = utils.cast(float, data.attrib.get('hostCpuUtilization')) self.hostMemoryUtilization = utils.cast(float, data.attrib.get('hostMemoryUtilization')) @@ -1279,7 +1279,7 @@ class ButlerTask(PlexObject): TAG = 'ButlerTask' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, 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')) @@ -1301,7 +1301,7 @@ def __repr__(self): return f"<{self.__class__.__name__}:{self.machineIdentifier}>" def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.claimed = utils.cast(bool, data.attrib.get('claimed')) self.machineIdentifier = data.attrib.get('machineIdentifier') self.version = data.attrib.get('version') diff --git a/plexapi/video.py b/plexapi/video.py index 9e4201b88..d1b4bad48 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +5,7 @@ from urllib.parse import quote_plus from plexapi import media, utils -from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession +from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession, cached_data_property from plexapi.exceptions import BadRequest from plexapi.mixins import ( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, @@ -48,13 +48,11 @@ class Video(PlexPartialObject, PlayedUnplayedMixin): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexPartialObject._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') - self.fields = self.findItems(data, media.Field) self.guid = data.attrib.get('guid') - self.images = self.findItems(data, media.Image) self.key = data.attrib.get('key', '') self.lastRatedAt = utils.toDatetime(data.attrib.get('lastRatedAt')) self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) @@ -73,6 +71,14 @@ def _loadData(self, data): self.userRating = utils.cast(float, data.attrib.get('userRating')) self.viewCount = utils.cast(int, data.attrib.get('viewCount', 0)) + @cached_data_property + def fields(self): + return self.findItems(self._data, media.Field) + + @cached_data_property + def images(self): + return self.findItems(self._data, media.Image) + def url(self, part): """ Returns the full url for something. Typically used for getting a specific image. """ return self._server.url(part, includeToken=True) if part else None @@ -394,41 +400,86 @@ def _loadData(self, data): Playable._loadData(self, data) self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) self.audienceRatingImage = data.attrib.get('audienceRatingImage') - self.chapters = self.findItems(data, media.Chapter) self.chapterSource = data.attrib.get('chapterSource') - self.collections = self.findItems(data, media.Collection) self.contentRating = data.attrib.get('contentRating') - self.countries = self.findItems(data, media.Country) - self.directors = self.findItems(data, media.Director) self.duration = utils.cast(int, data.attrib.get('duration')) self.editionTitle = data.attrib.get('editionTitle') self.enableCreditsMarkerGeneration = utils.cast(int, data.attrib.get('enableCreditsMarkerGeneration', '-1')) - self.genres = self.findItems(data, media.Genre) - self.guids = self.findItems(data, media.Guid) - self.labels = self.findItems(data, media.Label) self.languageOverride = data.attrib.get('languageOverride') - 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') self.originalTitle = data.attrib.get('originalTitle') self.primaryExtraKey = data.attrib.get('primaryExtraKey') - self.producers = self.findItems(data, media.Producer) self.rating = utils.cast(float, data.attrib.get('rating')) self.ratingImage = data.attrib.get('ratingImage') - self.ratings = self.findItems(data, media.Rating) - self.roles = self.findItems(data, media.Role) self.slug = data.attrib.get('slug') - self.similar = self.findItems(data, media.Similar) self.sourceURI = data.attrib.get('source') # remote playlist item 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) self.year = utils.cast(int, data.attrib.get('year')) + @cached_data_property + def chapters(self): + return self.findItems(self._data, media.Chapter) + + @cached_data_property + def collections(self): + return self.findItems(self._data, media.Collection) + + @cached_data_property + def countries(self): + return self.findItems(self._data, media.Country) + + @cached_data_property + def directors(self): + return self.findItems(self._data, media.Director) + + @cached_data_property + def genres(self): + return self.findItems(self._data, media.Genre) + + @cached_data_property + def guids(self): + return self.findItems(self._data, media.Guid) + + @cached_data_property + def labels(self): + return self.findItems(self._data, media.Label) + + @cached_data_property + def markers(self): + return self.findItems(self._data, media.Marker) + + @cached_data_property + def media(self): + return self.findItems(self._data, media.Media) + + @cached_data_property + def producers(self): + return self.findItems(self._data, media.Producer) + + @cached_data_property + def ratings(self): + return self.findItems(self._data, media.Rating) + + @cached_data_property + def roles(self): + return self.findItems(self._data, media.Role) + + @cached_data_property + def similar(self): + return self.findItems(self._data, media.Similar) + + @cached_data_property + def ultraBlurColors(self): + return self.findItem(self._data, media.UltraBlurColors) + + @cached_data_property + def writers(self): + return self.findItems(self._data, media.Writer) + @property def actors(self): """ Alias to self.roles. """ @@ -573,40 +624,67 @@ def _loadData(self, data): self.autoDeletionItemPolicyWatchedLibrary = utils.cast( int, data.attrib.get('autoDeletionItemPolicyWatchedLibrary', '0')) self.childCount = utils.cast(int, data.attrib.get('childCount')) - self.collections = self.findItems(data, media.Collection) self.contentRating = data.attrib.get('contentRating') self.duration = utils.cast(int, data.attrib.get('duration')) self.enableCreditsMarkerGeneration = utils.cast(int, data.attrib.get('enableCreditsMarkerGeneration', '-1')) self.episodeSort = utils.cast(int, data.attrib.get('episodeSort', '-1')) self.flattenSeasons = utils.cast(int, data.attrib.get('flattenSeasons', '-1')) - self.genres = self.findItems(data, media.Genre) - 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.languageOverride = data.attrib.get('languageOverride') self.leafCount = utils.cast(int, data.attrib.get('leafCount')) - self.locations = self.listAttrs(data, 'path', etag='Location') self.network = data.attrib.get('network') self.originallyAvailableAt = utils.toDatetime(data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.originalTitle = data.attrib.get('originalTitle') self.rating = utils.cast(float, data.attrib.get('rating')) - self.ratings = self.findItems(data, media.Rating) - self.roles = self.findItems(data, media.Role) self.seasonCount = utils.cast(int, data.attrib.get('seasonCount', self.childCount)) self.showOrdering = data.attrib.get('showOrdering') - self.similar = self.findItems(data, media.Similar) self.slug = data.attrib.get('slug') self.studio = data.attrib.get('studio') self.subtitleLanguage = data.attrib.get('subtitleLanguage', '') 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')) + @cached_data_property + def collections(self): + return self.findItems(self._data, media.Collection) + + @cached_data_property + def genres(self): + return self.findItems(self._data, media.Genre) + + @cached_data_property + def guids(self): + return self.findItems(self._data, media.Guid) + + @cached_data_property + def labels(self): + return self.findItems(self._data, media.Label) + + @cached_data_property + def locations(self): + return self.listAttrs(self._data, 'path', etag='Location') + + @cached_data_property + def ratings(self): + return self.findItems(self._data, media.Rating) + + @cached_data_property + def roles(self): + return self.findItems(self._data, media.Role) + + @cached_data_property + def similar(self): + return self.findItems(self._data, media.Similar) + + @cached_data_property + def ultraBlurColors(self): + return self.findItem(self._data, media.UltraBlurColors) + def __iter__(self): for season in self.seasons(): yield season @@ -759,11 +837,8 @@ def _loadData(self, data): Video._loadData(self, data) self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) self.audioLanguage = data.attrib.get('audioLanguage', '') - self.collections = self.findItems(data, media.Collection) - self.guids = self.findItems(data, media.Guid) self.index = utils.cast(int, data.attrib.get('index')) self.key = self.key.replace('/children', '') # FIX_BUG_50 - self.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')) @@ -775,13 +850,31 @@ def _loadData(self, data): self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.rating = utils.cast(float, data.attrib.get('rating')) - 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')) + @cached_property + def collections(self): + return self.findItems(self._data, media.Collection) + + @cached_property + def guids(self): + return self.findItems(self._data, media.Guid) + + @cached_property + def labels(self): + return self.findItems(self._data, media.Label) + + @cached_property + def ratings(self): + return self.findItems(self._data, media.Rating) + + @cached_property + def ultraBlurColors(self): + return self.findItem(self._data, media.UltraBlurColors) + def __iter__(self): for episode in self.episodes(): yield episode @@ -942,11 +1035,8 @@ def _loadData(self, data): Playable._loadData(self, data) self.audienceRating = utils.cast(float, data.attrib.get('audienceRating')) self.audienceRatingImage = data.attrib.get('audienceRatingImage') - self.chapters = self.findItems(data, media.Chapter) self.chapterSource = data.attrib.get('chapterSource') - self.collections = self.findItems(data, media.Collection) self.contentRating = data.attrib.get('contentRating') - self.directors = self.findItems(data, media.Director) self.duration = utils.cast(int, data.attrib.get('duration')) self.grandparentArt = data.attrib.get('grandparentArt') self.grandparentGuid = data.attrib.get('grandparentGuid') @@ -956,25 +1046,17 @@ def _loadData(self, data): self.grandparentTheme = data.attrib.get('grandparentTheme') self.grandparentThumb = data.attrib.get('grandparentThumb') 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') self.parentGuid = data.attrib.get('parentGuid') self.parentIndex = utils.cast(int, data.attrib.get('parentIndex')) self.parentTitle = data.attrib.get('parentTitle') self.parentYear = utils.cast(int, data.attrib.get('parentYear')) - self.producers = self.findItems(data, media.Producer) self.rating = utils.cast(float, data.attrib.get('rating')) - self.ratings = self.findItems(data, media.Rating) - 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')) # If seasons are hidden, parentKey and parentRatingKey are missing from the XML response. @@ -984,6 +1066,50 @@ def _loadData(self, data): self._parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey')) self._parentThumb = data.attrib.get('parentThumb') + @cached_data_property + def chapters(self): + return self.findItems(self._data, media.Chapter) + + @cached_data_property + def collections(self): + return self.findItems(self._data, media.Collection) + + @cached_data_property + def directors(self): + return self.findItems(self._data, media.Director) + + @cached_data_property + def guids(self): + return self.findItems(self._data, media.Guid) + + @cached_data_property + def labels(self): + return self.findItems(self._data, media.Label) + + @cached_data_property + def markers(self): + return self.findItems(self._data, media.Marker) + + @cached_data_property + def media(self): + return self.findItems(self._data, media.Media) + + @cached_data_property + def producers(self): + return self.findItems(self._data, media.Producer) + + @cached_data_property + def ratings(self): + return self.findItems(self._data, media.Rating) + + @cached_data_property + def roles(self): + return self.findItems(self._data, media.Role) + + @cached_data_property + def writers(self): + return self.findItems(self._data, media.Writer) + @cached_property def parentKey(self): """ Returns the parentKey. Refer to the Episode attributes. """ @@ -1149,12 +1275,10 @@ def _loadData(self, data): """ Load attribute values from Plex XML response. """ Video._loadData(self, data) Playable._loadData(self, data) - self._data = data self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.duration = utils.cast(int, data.attrib.get('duration')) self.extraType = utils.cast(int, data.attrib.get('extraType')) self.index = utils.cast(int, data.attrib.get('index')) - self.media = self.findItems(data, media.Media) self.originallyAvailableAt = utils.toDatetime( data.attrib.get('originallyAvailableAt'), '%Y-%m-%d') self.skipDetails = utils.cast(int, data.attrib.get('skipDetails')) @@ -1163,6 +1287,10 @@ def _loadData(self, data): self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.year = utils.cast(int, data.attrib.get('year')) + @cached_data_property + def media(self): + return self.findItems(self._data, media.Media) + @property def locations(self): """ This does not exist in plex xml response but is added to have a common From d8860c95c9b72948242b2473b1daf88c6b36bb81 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Fri, 4 Apr 2025 01:57:57 +0000 Subject: [PATCH 03/33] fix: Don't invalidate property cache on object initialization --- plexapi/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/base.py b/plexapi/base.py index 4805503a3..21bb759a3 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -89,7 +89,7 @@ class PlexObject(metaclass=PlexObjectMeta): def __init__(self, server, data, initpath=None, parent=None): self._server = server - PlexObject._loadData(self, data) + self._data = data self._initpath = initpath or self.key self._parent = weakref.ref(parent) if parent is not None else None self._details_key = None From c31f7c6967aeb3c036b7c19ae1640089a8b9122d Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Fri, 4 Apr 2025 01:59:11 +0000 Subject: [PATCH 04/33] refactor: For all Plex objects, call the base class's loadData function to do cache invalidation --- plexapi/base.py | 2 +- plexapi/client.py | 4 ++-- plexapi/library.py | 31 +++++++++++++++++++------------ plexapi/media.py | 39 ++++++++++++++++++++------------------- plexapi/myplex.py | 8 ++++---- plexapi/settings.py | 2 +- plexapi/sonos.py | 3 ++- plexapi/sync.py | 4 ++-- 8 files changed, 51 insertions(+), 42 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 21bb759a3..475daa6ac 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -1174,7 +1174,7 @@ def extend( setattr(self, key, getattr(__iterable, key)) def _loadData(self, data): - self._data = data + PlexPartialObject._loadData(self, data) self.allowSync = utils.cast(int, data.attrib.get('allowSync')) self.augmentationKey = data.attrib.get('augmentationKey') self.identifier = data.attrib.get('identifier') diff --git a/plexapi/client.py b/plexapi/client.py index 3d89e3dc6..424ca2e85 100644 --- a/plexapi/client.py +++ b/plexapi/client.py @@ -124,7 +124,7 @@ def reload(self): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.deviceClass = data.attrib.get('deviceClass') self.machineIdentifier = data.attrib.get('machineIdentifier') self.product = data.attrib.get('product') @@ -606,7 +606,7 @@ class ClientTimeline(PlexObject): key = 'timeline/poll' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.address = data.attrib.get('address') self.audioStreamId = utils.cast(int, data.attrib.get('audioStreamId')) self.autoPlay = utils.cast(bool, data.attrib.get('autoPlay')) diff --git a/plexapi/library.py b/plexapi/library.py index b38157b30..f3552633e 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -39,7 +39,7 @@ class Library(PlexObject): key = '/library' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.identifier = data.attrib.get('identifier') self.mediaTagVersion = data.attrib.get('mediaTagVersion') self.title1 = data.attrib.get('title1') @@ -432,7 +432,7 @@ class LibrarySection(PlexObject): """ def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.agent = data.attrib.get('agent') self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) self.art = data.attrib.get('art') @@ -441,7 +441,6 @@ def _loadData(self, data): self.filters = utils.cast(bool, data.attrib.get('filters')) self.key = utils.cast(int, data.attrib.get('key')) self.language = data.attrib.get('language') - self.locations = self.listAttrs(data, 'path', etag='Location') self.refreshing = utils.cast(bool, data.attrib.get('refreshing')) self.scanner = data.attrib.get('scanner') self.thumb = data.attrib.get('thumb') @@ -456,6 +455,10 @@ def _loadData(self, data): self._totalDuration = None self._totalStorage = None + @cached_data_property + def locations(self): + return self.listAttrs(self._data, 'path', etag='Location') + @cached_property def totalSize(self): """ Returns the total number of items in the library for the default library type. """ @@ -2165,7 +2168,7 @@ class LibraryTimeline(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.size = utils.cast(int, data.attrib.get('size')) self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) self.art = data.attrib.get('art') @@ -2194,7 +2197,7 @@ class Location(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.id = utils.cast(int, data.attrib.get('id')) self.path = data.attrib.get('path') @@ -2220,7 +2223,7 @@ class Hub(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.context = data.attrib.get('context') self.hubKey = data.attrib.get('hubKey') self.hubIdentifier = data.attrib.get('hubIdentifier') @@ -2869,7 +2872,7 @@ class FilteringFilter(PlexObject): TAG = 'Filter' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.filter = data.attrib.get('filter') self.filterType = data.attrib.get('filterType') self.key = data.attrib.get('key') @@ -2895,7 +2898,7 @@ class FilteringSort(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.active = utils.cast(bool, data.attrib.get('active', '0')) self.activeDirection = data.attrib.get('activeDirection') self.default = data.attrib.get('default') @@ -2920,7 +2923,7 @@ class FilteringField(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.key = data.attrib.get('key') self.title = data.attrib.get('title') self.type = data.attrib.get('type') @@ -2963,6 +2966,7 @@ class FilteringOperator(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ + PlexObject._loadData(self, data) self.key = data.attrib.get('key') self.title = data.attrib.get('title') @@ -2985,7 +2989,7 @@ class FilterChoice(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.fastKey = data.attrib.get('fastKey') self.key = data.attrib.get('key') self.thumb = data.attrib.get('thumb') @@ -3015,7 +3019,7 @@ class ManagedHub(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.deletable = utils.cast(bool, data.attrib.get('deletable', True)) self.homeVisibility = data.attrib.get('homeVisibility', 'none') self.identifier = data.attrib.get('identifier') @@ -3139,6 +3143,7 @@ class Folder(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ + PlexObject._loadData(self, data) self.key = data.attrib.get('key') self.title = data.attrib.get('title') @@ -3179,7 +3184,7 @@ class FirstCharacter(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.key = data.attrib.get('key') self.size = data.attrib.get('size') self.title = data.attrib.get('title') @@ -3200,6 +3205,7 @@ class Path(PlexObject): TAG = 'Path' def _loadData(self, data): + PlexObject._loadData(self, data) self.home = utils.cast(bool, data.attrib.get('home')) self.key = data.attrib.get('key') self.network = utils.cast(bool, data.attrib.get('network')) @@ -3229,6 +3235,7 @@ class File(PlexObject): TAG = 'File' def _loadData(self, data): + PlexObject._loadData(self, data) self.key = data.attrib.get('key') self.path = data.attrib.get('path') self.title = data.attrib.get('title') diff --git a/plexapi/media.py b/plexapi/media.py index bb688974a..33ce158ca 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -141,7 +141,7 @@ class MediaPart(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.accessible = utils.cast(bool, data.attrib.get('accessible')) self.audioProfile = data.attrib.get('audioProfile') self.container = data.attrib.get('container') @@ -271,7 +271,7 @@ class MediaPartStream(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.bitrate = utils.cast(int, data.attrib.get('bitrate')) self.codec = data.attrib.get('codec') self.decision = data.attrib.get('decision') @@ -572,7 +572,7 @@ class TranscodeSession(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.audioChannels = utils.cast(int, data.attrib.get('audioChannels')) self.audioCodec = data.attrib.get('audioCodec') self.audioDecision = data.attrib.get('audioDecision') @@ -613,7 +613,7 @@ class TranscodeJob(PlexObject): TAG = 'TranscodeJob' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.generatorID = data.attrib.get('generatorID') self.key = data.attrib.get('key') self.progress = data.attrib.get('progress') @@ -632,7 +632,7 @@ class Optimized(PlexObject): TAG = 'Item' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.id = data.attrib.get('id') self.composite = data.attrib.get('composite') self.title = data.attrib.get('title') @@ -670,7 +670,7 @@ class Conversion(PlexObject): TAG = 'Video' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.addedAt = data.attrib.get('addedAt') self.art = data.attrib.get('art') self.chapterSource = data.attrib.get('chapterSource') @@ -746,7 +746,7 @@ def __str__(self): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.filter = data.attrib.get('filter') self.id = utils.cast(int, data.attrib.get('id')) self.key = data.attrib.get('key') @@ -957,7 +957,7 @@ class Guid(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.id = data.attrib.get('id') @@ -975,7 +975,7 @@ class Image(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.alt = data.attrib.get('alt') self.type = data.attrib.get('type') self.url = data.attrib.get('url') @@ -997,7 +997,7 @@ class Rating(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.image = data.attrib.get('image') self.type = data.attrib.get('type') self.value = utils.cast(float, data.attrib.get('value')) @@ -1020,7 +1020,7 @@ class Review(PlexObject): TAG = 'Review' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.filter = data.attrib.get('filter') self.id = utils.cast(int, data.attrib.get('id', 0)) self.image = data.attrib.get('image') @@ -1045,7 +1045,7 @@ class UltraBlurColors(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.bottomLeft = data.attrib.get('bottomLeft') self.bottomRight = data.attrib.get('bottomRight') self.topLeft = data.attrib.get('topLeft') @@ -1066,7 +1066,7 @@ class BaseResource(PlexObject): """ def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.key = data.attrib.get('key') self.provider = data.attrib.get('provider') self.ratingKey = data.attrib.get('ratingKey') @@ -1141,7 +1141,7 @@ def __repr__(self): return f"<{':'.join([self.__class__.__name__, name, offsets])}>" def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.end = utils.cast(int, data.attrib.get('endTimeOffset')) self.filter = data.attrib.get('filter') self.id = utils.cast(int, data.attrib.get('id', 0)) @@ -1175,7 +1175,7 @@ def __repr__(self): return f"<{':'.join([self.__class__.__name__, name, offsets])}>" def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.end = utils.cast(int, data.attrib.get('endTimeOffset')) self.final = utils.cast(bool, data.attrib.get('final')) self.id = utils.cast(int, data.attrib.get('id')) @@ -1209,7 +1209,7 @@ class Field(PlexObject): TAG = 'Field' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.locked = utils.cast(bool, data.attrib.get('locked')) self.name = data.attrib.get('name') @@ -1229,7 +1229,7 @@ def __repr__(self): return f"<{':'.join([p for p in [self.__class__.__name__, name, score] if p])}>" def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.guid = data.attrib.get('guid') self.lifespanEnded = data.attrib.get('lifespanEnded') self.name = data.attrib.get('name') @@ -1251,7 +1251,7 @@ def __repr__(self): return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>" def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.hasAttribution = data.attrib.get('hasAttribution') self.hasPrefs = data.attrib.get('hasPrefs') self.identifier = data.attrib.get('identifier') @@ -1259,6 +1259,7 @@ def _loadData(self, data): self.primary = data.attrib.get('primary') self.shortIdentifier = self.identifier.rsplit('.', 1)[1] + # TODO: How should the cached data property be handled here? if 'mediaType' in self._initpath: self.languageCodes = self.listAttrs(data, 'code', etag='Language') self.mediaTypes = [] @@ -1331,7 +1332,7 @@ def __repr__(self): return f'<{self.__class__.__name__}:{self.platform}:{self.offerType}>' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.country = data.attrib.get('country') self.offerType = data.attrib.get('offerType') self.platform = data.attrib.get('platform') diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 602413f64..35fe65a82 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -1329,7 +1329,7 @@ class Section(PlexObject): TAG = 'Section' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.id = utils.cast(int, data.attrib.get('id')) self.key = utils.cast(int, data.attrib.get('key')) self.shared = utils.cast(bool, data.attrib.get('shared', '0')) @@ -1368,7 +1368,7 @@ class MyPlexServerShare(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) self.id = utils.cast(int, data.attrib.get('id')) self.accountID = utils.cast(int, data.attrib.get('accountID')) self.serverId = utils.cast(int, data.attrib.get('serverId')) @@ -1573,7 +1573,7 @@ class ResourceConnection(PlexObject): TAG = 'connection' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.address = data.attrib.get('address') self.ipv6 = utils.cast(bool, data.attrib.get('IPv6')) self.local = utils.cast(bool, data.attrib.get('local')) @@ -2047,7 +2047,7 @@ class GeoLocation(PlexObject): TAG = 'location' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.city = data.attrib.get('city') self.code = data.attrib.get('code') self.continentCode = data.attrib.get('continent_code') diff --git a/plexapi/settings.py b/plexapi/settings.py index c191e3689..4f02232c4 100644 --- a/plexapi/settings.py +++ b/plexapi/settings.py @@ -34,7 +34,7 @@ def __setattr__(self, attr, value): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data + PlexObject._loadData(self, data) for elem in data: id = utils.lowerFirst(elem.attrib['id']) if id in self._settings: diff --git a/plexapi/sonos.py b/plexapi/sonos.py index 8f1295f44..0f387c119 100644 --- a/plexapi/sonos.py +++ b/plexapi/sonos.py @@ -5,6 +5,7 @@ from plexapi.client import PlexClient from plexapi.exceptions import BadRequest from plexapi.playqueue import PlayQueue +from plexapi.base import PlexObject class PlexSonosClient(PlexClient): @@ -47,7 +48,7 @@ class PlexSonosClient(PlexClient): """ def __init__(self, account, data, timeout=None): - self._data = data + PlexObject._loadData(self, data) self.deviceClass = data.attrib.get("deviceClass") self.machineIdentifier = data.attrib.get("machineIdentifier") self.product = data.attrib.get("product") diff --git a/plexapi/sync.py b/plexapi/sync.py index f57e89d96..0c3e5c4ff 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -63,7 +63,7 @@ def __init__(self, server, data, initpath=None, clientIdentifier=None): self.clientIdentifier = clientIdentifier def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.id = plexapi.utils.cast(int, data.attrib.get('id')) self.version = plexapi.utils.cast(int, data.attrib.get('version')) self.rootTitle = data.attrib.get('rootTitle') @@ -118,7 +118,7 @@ class SyncList(PlexObject): TAG = 'SyncList' def _loadData(self, data): - self._data = data + PlexObject._loadData(self, data) self.clientId = data.attrib.get('clientIdentifier') self.items = [] From 9d5abc9aa19acbd2c51eaa393bf11837e2f04668 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Fri, 4 Apr 2025 03:14:12 +0000 Subject: [PATCH 05/33] perf: Convert attributes that call `findItem` to cached data properties --- plexapi/audio.py | 5 ++++- plexapi/collection.py | 5 ++++- plexapi/video.py | 5 ++++- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index ca43785f5..8f38a21de 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -403,7 +403,6 @@ def _loadData(self, data): self.parentTitle = data.attrib.get('parentTitle') self.rating = utils.cast(float, data.attrib.get('rating')) self.studio = data.attrib.get('studio') - 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')) @@ -435,6 +434,10 @@ def styles(self): def subformats(self): return self.findItems(self._data, media.Subformat) + @cached_data_property + def ultraBlurColors(self): + return self.findItem(self._data, media.UltraBlurColors) + def __iter__(self): for track in self.tracks(): yield track diff --git a/plexapi/collection.py b/plexapi/collection.py index 93db6b643..abcc5f4a2 100644 --- a/plexapi/collection.py +++ b/plexapi/collection.py @@ -102,7 +102,6 @@ def _loadData(self, data): 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 @@ -121,6 +120,10 @@ def images(self): def labels(self): return self.findItems(self._data, media.Label) + @cached_data_property + def ultraBlurColors(self): + return self.findItem(self._data, media.UltraBlurColors) + def __len__(self): # pragma: no cover return len(self.items()) diff --git a/plexapi/video.py b/plexapi/video.py index d1b4bad48..8c8595d5d 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -1055,7 +1055,6 @@ def _loadData(self, data): self.rating = utils.cast(float, data.attrib.get('rating')) 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.year = utils.cast(int, data.attrib.get('year')) @@ -1110,6 +1109,10 @@ def roles(self): def writers(self): return self.findItems(self._data, media.Writer) + @cached_data_property + def ultraBlurColors(self): + return self.findItem(self._data, media.UltraBlurColors) + @cached_property def parentKey(self): """ Returns the parentKey. Refer to the Episode attributes. """ From e8348dfae0e809d5c3e7eb7db6335b58eaeb2e4d Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Fri, 4 Apr 2025 15:22:30 +0000 Subject: [PATCH 06/33] perf: Attempt to parse XML strings without cleaning (which is expensive) before trying again with cleaning --- plexapi/client.py | 3 +-- plexapi/myplex.py | 3 +-- plexapi/server.py | 3 +-- plexapi/utils.py | 12 ++++++++++++ 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/plexapi/client.py b/plexapi/client.py index 424ca2e85..d9d9dfabd 100644 --- a/plexapi/client.py +++ b/plexapi/client.py @@ -197,8 +197,7 @@ def query(self, path, method=None, headers=None, timeout=None, **kwargs): raise NotFound(message) else: raise BadRequest(message) - data = utils.cleanXMLString(response.text).encode('utf8') - return ElementTree.fromstring(data) if data.strip() else None + return utils.parseXMLString(response.text) def sendCommand(self, command, proxy=None, **params): """ Convenience wrapper around :func:`~plexapi.client.PlexClient.query` to more easily diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 35fe65a82..01e09cbb9 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -259,8 +259,7 @@ def query(self, url, method=None, headers=None, timeout=None, **kwargs): return response.json() elif 'text/plain' in response.headers.get('Content-Type', ''): return response.text.strip() - data = utils.cleanXMLString(response.text).encode('utf8') - return ElementTree.fromstring(data) if data.strip() else None + return utils.parseXMLString(response.text) def ping(self): """ Ping the Plex.tv API. diff --git a/plexapi/server.py b/plexapi/server.py index c99d7a408..e20a2ef7d 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -768,8 +768,7 @@ def query(self, key, method=None, headers=None, params=None, timeout=None, **kwa raise NotFound(message) else: raise BadRequest(message) - data = utils.cleanXMLString(response.text).encode('utf8') - return ElementTree.fromstring(data) if data.strip() else None + return utils.parseXMLString(response.text) def search(self, query, mediatype=None, limit=None, sectionId=None): """ Returns a list of media items or filter categories from the resulting diff --git a/plexapi/utils.py b/plexapi/utils.py index dd1cfc9ce..1a2107de2 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -18,6 +18,8 @@ from threading import Event, Thread from urllib.parse import quote +from xml.etree import ElementTree + import requests from requests.status_codes import _codes as codes @@ -718,3 +720,13 @@ def sha1hash(guid): def cleanXMLString(s): return _illegal_XML_re.sub('', s) + + +def parseXMLString(s: str): + """ Parse an XML string and return an ElementTree object. """ + if not s.strip(): + return None + try: # Attempt to parse the string as-is without cleaning (which is expensive) + return ElementTree.fromstring(s.encode('utf-8')) + except ElementTree.ParseError: # If it fails, clean the string and try again + return ElementTree.fromstring(cleanXMLString(s).encode('utf-8')) From aed1fd9004666d83d80b7397338ce544d004b426 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Fri, 4 Apr 2025 16:09:03 +0000 Subject: [PATCH 07/33] Revert "perf: Attempt to parse XML strings without cleaning (which is expensive) before trying again with cleaning" This reverts commit e8348dfae0e809d5c3e7eb7db6335b58eaeb2e4d. --- plexapi/client.py | 3 ++- plexapi/myplex.py | 3 ++- plexapi/server.py | 3 ++- plexapi/utils.py | 12 ------------ 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/plexapi/client.py b/plexapi/client.py index d9d9dfabd..424ca2e85 100644 --- a/plexapi/client.py +++ b/plexapi/client.py @@ -197,7 +197,8 @@ def query(self, path, method=None, headers=None, timeout=None, **kwargs): raise NotFound(message) else: raise BadRequest(message) - return utils.parseXMLString(response.text) + data = utils.cleanXMLString(response.text).encode('utf8') + return ElementTree.fromstring(data) if data.strip() else None def sendCommand(self, command, proxy=None, **params): """ Convenience wrapper around :func:`~plexapi.client.PlexClient.query` to more easily diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 01e09cbb9..35fe65a82 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -259,7 +259,8 @@ def query(self, url, method=None, headers=None, timeout=None, **kwargs): return response.json() elif 'text/plain' in response.headers.get('Content-Type', ''): return response.text.strip() - return utils.parseXMLString(response.text) + data = utils.cleanXMLString(response.text).encode('utf8') + return ElementTree.fromstring(data) if data.strip() else None def ping(self): """ Ping the Plex.tv API. diff --git a/plexapi/server.py b/plexapi/server.py index e20a2ef7d..c99d7a408 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -768,7 +768,8 @@ def query(self, key, method=None, headers=None, params=None, timeout=None, **kwa raise NotFound(message) else: raise BadRequest(message) - return utils.parseXMLString(response.text) + 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): """ Returns a list of media items or filter categories from the resulting diff --git a/plexapi/utils.py b/plexapi/utils.py index 1a2107de2..dd1cfc9ce 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -18,8 +18,6 @@ from threading import Event, Thread from urllib.parse import quote -from xml.etree import ElementTree - import requests from requests.status_codes import _codes as codes @@ -720,13 +718,3 @@ def sha1hash(guid): def cleanXMLString(s): return _illegal_XML_re.sub('', s) - - -def parseXMLString(s: str): - """ Parse an XML string and return an ElementTree object. """ - if not s.strip(): - return None - try: # Attempt to parse the string as-is without cleaning (which is expensive) - return ElementTree.fromstring(s.encode('utf-8')) - except ElementTree.ParseError: # If it fails, clean the string and try again - return ElementTree.fromstring(cleanXMLString(s).encode('utf-8')) From da10d3574aa9f378bc4b5b8d395c33266913c422 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Fri, 4 Apr 2025 19:57:21 +0000 Subject: [PATCH 08/33] fix: Use the correct attribute name when deleting invalidated cached data --- plexapi/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/base.py b/plexapi/base.py index 475daa6ac..d4acdf01b 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -537,7 +537,7 @@ def _invalidateCachedProperties(self): cached_props = getattr(self.__class__, '_cached_data_properties', set()) for prop_name in cached_props: - cache_name = f"_{prop_name}" + cache_name = prop_name if cache_name in self.__dict__: del self.__dict__[cache_name] From f976cf03c475379f0bad2dda807541c0c0d6fb09 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Fri, 4 Apr 2025 20:05:54 +0000 Subject: [PATCH 09/33] fix: Follow the same behavior as before the introduction of cached properties and don't call the super class' _loadData --- plexapi/audio.py | 4 ++-- plexapi/base.py | 2 +- plexapi/collection.py | 4 ++-- plexapi/video.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 8f38a21de..ff4655145 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional, TypeVar from plexapi import media, utils -from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession, cached_data_property +from plexapi.base import Playable, PlexObject, PlexPartialObject, PlexHistory, PlexSession, cached_data_property from plexapi.exceptions import BadRequest from plexapi.mixins import ( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, @@ -59,7 +59,7 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexPartialObject._loadData(self, data) + PlexObject._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') diff --git a/plexapi/base.py b/plexapi/base.py index d4acdf01b..444d134b5 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -1174,7 +1174,7 @@ def extend( setattr(self, key, getattr(__iterable, key)) def _loadData(self, data): - PlexPartialObject._loadData(self, data) + PlexObject._loadData(self, data) self.allowSync = utils.cast(int, data.attrib.get('allowSync')) self.augmentationKey = data.attrib.get('augmentationKey') self.identifier = data.attrib.get('identifier') diff --git a/plexapi/collection.py b/plexapi/collection.py index abcc5f4a2..dc2c0caa5 100644 --- a/plexapi/collection.py +++ b/plexapi/collection.py @@ -3,7 +3,7 @@ from urllib.parse import quote_plus from plexapi import media, utils -from plexapi.base import PlexPartialObject, cached_data_property +from plexapi.base import PlexObject, PlexPartialObject, cached_data_property from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection, ManagedHub from plexapi.mixins import ( @@ -69,7 +69,7 @@ class Collection( TYPE = 'collection' def _loadData(self, data): - PlexPartialObject._loadData(self, data) + PlexObject._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') diff --git a/plexapi/video.py b/plexapi/video.py index 8c8595d5d..a27758d6d 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +5,7 @@ from urllib.parse import quote_plus from plexapi import media, utils -from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession, cached_data_property +from plexapi.base import Playable, PlexObject, PlexPartialObject, PlexHistory, PlexSession, cached_data_property from plexapi.exceptions import BadRequest from plexapi.mixins import ( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, @@ -48,7 +48,7 @@ class Video(PlexPartialObject, PlayedUnplayedMixin): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexPartialObject._loadData(self, data) + PlexObject._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') From 526e47b01e986d6a6a779246c5d341f5a1403c65 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Fri, 4 Apr 2025 23:12:37 +0000 Subject: [PATCH 10/33] fix: Typo in declaring cached data property attributes --- plexapi/video.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plexapi/video.py b/plexapi/video.py index a27758d6d..6e9b4727b 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -855,23 +855,23 @@ def _loadData(self, data): self.viewedLeafCount = utils.cast(int, data.attrib.get('viewedLeafCount')) self.year = utils.cast(int, data.attrib.get('year')) - @cached_property + @cached_data_property def collections(self): return self.findItems(self._data, media.Collection) - @cached_property + @cached_data_property def guids(self): return self.findItems(self._data, media.Guid) - @cached_property + @cached_data_property def labels(self): return self.findItems(self._data, media.Label) - @cached_property + @cached_data_property def ratings(self): return self.findItems(self._data, media.Rating) - @cached_property + @cached_data_property def ultraBlurColors(self): return self.findItem(self._data, media.UltraBlurColors) From c3cd3735bf9fca4018d456363f7e65bc89c3081d Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Fri, 4 Apr 2025 23:23:21 +0000 Subject: [PATCH 11/33] test: Don't use ` __dict__` to access attributes --- tests/test_video.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_video.py b/tests/test_video.py index e7f9c8dbe..b0f054046 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -350,8 +350,8 @@ def test_video_Movie_reload_kwargs(movie): assert len(movie.media) assert movie.summary is not None movie.reload(includeFields=False, **movie._EXCLUDES) - assert movie.__dict__.get('media') == [] - assert movie.__dict__.get('summary') is None + assert movie.media == [] + assert movie.summary is None def test_video_movie_watched(movie): From 83e6286a61d8943aa97e62bed0e4a77a443e12f1 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Sat, 5 Apr 2025 02:39:55 +0000 Subject: [PATCH 12/33] test: Don't reload objects for the test_video_Movie_reload_kwargs test --- tests/test_video.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_video.py b/tests/test_video.py index b0f054046..7fbf80b77 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -350,8 +350,12 @@ def test_video_Movie_reload_kwargs(movie): assert len(movie.media) assert movie.summary is not None movie.reload(includeFields=False, **movie._EXCLUDES) + # Prevent auto reloading when using getattr on `media` and `summary` + original_auto_reload = movie._autoReload + movie._autoReload = False assert movie.media == [] assert movie.summary is None + movie._autoReload = original_auto_reload def test_video_movie_watched(movie): From 0ef26edf074a64d95c5e77ada1f0ad16969ecae8 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Sat, 5 Apr 2025 02:50:12 +0000 Subject: [PATCH 13/33] fix: Ensure `PlexObject._loadData` is called in child classes that override loadData --- plexapi/media.py | 2 ++ plexapi/myplex.py | 2 ++ plexapi/photo.py | 4 +++- plexapi/playlist.py | 3 ++- plexapi/server.py | 1 + plexapi/settings.py | 1 + 6 files changed, 11 insertions(+), 2 deletions(-) diff --git a/plexapi/media.py b/plexapi/media.py index 33ce158ca..9325de1fa 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -526,6 +526,7 @@ class Session(PlexObject): TAG = 'Session' def _loadData(self, data): + PlexObject._loadData(self, data) self.id = data.attrib.get('id') self.bandwidth = utils.cast(int, data.attrib.get('bandwidth')) self.location = data.attrib.get('location') @@ -1295,6 +1296,7 @@ def __repr__(self): return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>" def _loadData(self, data): + PlexObject._loadData(self, data) self.mediaType = utils.cast(int, data.attrib.get('mediaType')) self.name = data.attrib.get('name') diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 35fe65a82..ed9497e79 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -1960,6 +1960,7 @@ class AccountOptOut(PlexObject): CHOICES = {'opt_in', 'opt_out', 'opt_out_managed'} def _loadData(self, data): + PlexObject._loadData(self, data) self.key = data.attrib.get('key') self.value = data.attrib.get('value') @@ -2018,6 +2019,7 @@ def __repr__(self): return f'<{self.__class__.__name__}:{self.ratingKey}>' def _loadData(self, data): + PlexObject._loadData(self, data) self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) self.ratingKey = data.attrib.get('ratingKey') self.type = data.attrib.get('type') diff --git a/plexapi/photo.py b/plexapi/photo.py index e7c7239e8..ea99aad70 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -4,7 +4,7 @@ from urllib.parse import quote_plus from plexapi import media, utils, video -from plexapi.base import Playable, PlexPartialObject, PlexSession, cached_data_property +from plexapi.base import Playable, PlexObject, PlexPartialObject, PlexSession, cached_data_property from plexapi.exceptions import BadRequest from plexapi.mixins import ( RatingMixin, @@ -53,6 +53,7 @@ class Photoalbum( def _loadData(self, data): """ Load attribute values from Plex XML response. """ + PlexObject._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.composite = data.attrib.get('composite') @@ -207,6 +208,7 @@ class Photo( def _loadData(self, data): """ Load attribute values from Plex XML response. """ + PlexObject._loadData(self, data) Playable._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.createdAtAccuracy = data.attrib.get('createdAtAccuracy') diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 0662e6165..51924497c 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -5,7 +5,7 @@ from urllib.parse import quote_plus, unquote from plexapi import media, utils -from plexapi.base import Playable, PlexPartialObject, cached_data_property +from plexapi.base import Playable, PlexObject, PlexPartialObject, cached_data_property from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection, MusicSection from plexapi.mixins import SmartFilterMixin, ArtMixin, PosterMixin, PlaylistEditMixins @@ -53,6 +53,7 @@ class Playlist( def _loadData(self, data): """ Load attribute values from Plex XML response. """ + PlexObject._loadData(self, data) Playable._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) diff --git a/plexapi/server.py b/plexapi/server.py index c99d7a408..394e0cdb7 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -1129,6 +1129,7 @@ class Release(PlexObject): key = '/updater/status' def _loadData(self, data): + PlexObject._loadData(self, data) self.download_key = data.attrib.get('key') self.version = data.attrib.get('version') self.added = data.attrib.get('added') diff --git a/plexapi/settings.py b/plexapi/settings.py index 4f02232c4..37f7bd35b 100644 --- a/plexapi/settings.py +++ b/plexapi/settings.py @@ -113,6 +113,7 @@ class Setting(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ + PlexObject._loadData(self, data) self.type = data.attrib.get('type') self.advanced = utils.cast(bool, data.attrib.get('advanced')) self.default = self._cast(data.attrib.get('default')) From 4f66a0afdcdf436d2cae6326ebed129ba1c146c7 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Sat, 5 Apr 2025 02:59:29 +0000 Subject: [PATCH 14/33] fix: Handle special cache invalidation for LibrarySection objects --- plexapi/library.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plexapi/library.py b/plexapi/library.py index f3552633e..75536ec2f 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -546,6 +546,7 @@ def reload(self): self._server.library._loadSections() newLibrary = self._server.library.sectionByID(self.key) self.__dict__.update(newLibrary.__dict__) + self._invalidateCachedProperties() return self def edit(self, agent=None, **kwargs): From 947393dbf54020cd8d62d44a17f472149723d93f Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Sat, 5 Apr 2025 03:45:50 +0000 Subject: [PATCH 15/33] test: Tests for cache invalidation in library and video objects --- tests/test_library.py | 15 +++++++++++++++ tests/test_video.py | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/tests/test_library.py b/tests/test_library.py index 261f63716..0da2a3cf4 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -959,3 +959,18 @@ def test_library_multiedit_exceptions(music, artist, album, photos): music.batchMultiEdits(artist).editEdition("test") with pytest.raises(AttributeError): music.batchMultiEdits(album).addCountry("test") + + +def test_library_section_cache_invalidation(movies): + # locations is one of the cached properties + with pytest.raises(KeyError): + movies.__dict__["locations"] + before_locations = movies.locations + before_id = id(before_locations) + movies.reload() + with pytest.raises(KeyError): + movies.__dict__["locations"] + after_locations = movies.locations + after_id = id(after_locations) + assert before_id != after_id, "Locations should have a new object ID after a reload" + assert before_locations == after_locations, "Locations should not have changed content after a library reload" diff --git a/tests/test_video.py b/tests/test_video.py index 7fbf80b77..c7f1b5568 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -1540,3 +1540,18 @@ def test_video_Movie_matadataDirectory(movie): for art in movie.arts(): if not art.ratingKey.startswith('http'): assert os.path.exists(os.path.join(utils.BOOTSTRAP_DATA_PATH, art.resourceFilepath)) + + +def test_video_cache_invalidation(movie): + # guids is one of the cached properties + with pytest.raises(KeyError): + movie.__dict__["guids"] + before_guids = movie.guids + before_id = id(before_guids) + movie.reload() + with pytest.raises(KeyError): + movie.__dict__["guids"] + after_guids = movie.guids + after_id = id(after_guids) + assert before_id != after_id, "GUIDs should have a new object ID after a reload" + assert before_guids == after_guids, "GUIDs should not have changed content after a reload" From 2212c2d68081207316557cbc40af72fa61159f60 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Sat, 5 Apr 2025 04:14:26 +0000 Subject: [PATCH 16/33] test: Removed unexpected exception from test_library_section_cache_invalidation --- tests/test_library.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_library.py b/tests/test_library.py index 0da2a3cf4..8e070cd53 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -963,8 +963,6 @@ def test_library_multiedit_exceptions(music, artist, album, photos): def test_library_section_cache_invalidation(movies): # locations is one of the cached properties - with pytest.raises(KeyError): - movies.__dict__["locations"] before_locations = movies.locations before_id = id(before_locations) movies.reload() From aea4282b9ed9e701c1d5159f113af4f14b7a9f80 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Sat, 5 Apr 2025 04:15:22 +0000 Subject: [PATCH 17/33] test: Replaced incorrect object ID comparisons with string string repr comparisons --- tests/test_library.py | 2 +- tests/test_video.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_library.py b/tests/test_library.py index 8e070cd53..867302614 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -971,4 +971,4 @@ def test_library_section_cache_invalidation(movies): after_locations = movies.locations after_id = id(after_locations) assert before_id != after_id, "Locations should have a new object ID after a reload" - assert before_locations == after_locations, "Locations should not have changed content after a library reload" + assert str(before_locations) == str(after_locations), "Locations should not have changed content after a library reload" diff --git a/tests/test_video.py b/tests/test_video.py index c7f1b5568..e7c2ad1c5 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -1554,4 +1554,4 @@ def test_video_cache_invalidation(movie): after_guids = movie.guids after_id = id(after_guids) assert before_id != after_id, "GUIDs should have a new object ID after a reload" - assert before_guids == after_guids, "GUIDs should not have changed content after a reload" + assert str(before_guids) == str(after_guids), "GUIDs should not have changed content after a reload" From 8fa7d139bbe8fa8422b11b2934daa3adc6d3dce7 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Sun, 6 Apr 2025 20:06:16 -0400 Subject: [PATCH 18/33] refactor: Removed uneeded variable assignment Co-authored-by: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> --- plexapi/base.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 444d134b5..0a99e335e 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -537,9 +537,8 @@ def _invalidateCachedProperties(self): cached_props = getattr(self.__class__, '_cached_data_properties', set()) for prop_name in cached_props: - cache_name = prop_name - if cache_name in self.__dict__: - del self.__dict__[cache_name] + if prop_name in self.__dict__: + del self.__dict__[prop_name] def _loadData(self, data): """Load attribute values from Plex XML response and invalidate cached properties.""" From cd2c8f70f40e01798319f9b51ca138b81a41922e Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Mon, 7 Apr 2025 00:14:55 +0000 Subject: [PATCH 19/33] perf: Convert languageCodes and mediaTypes to lazy-loaded cached properties --- plexapi/media.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/plexapi/media.py b/plexapi/media.py index 9325de1fa..0b15aaf09 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -1260,13 +1260,17 @@ def _loadData(self, data): self.primary = data.attrib.get('primary') self.shortIdentifier = self.identifier.rsplit('.', 1)[1] - # TODO: How should the cached data property be handled here? + @cached_data_property + def languageCodes(self): if 'mediaType' in self._initpath: - self.languageCodes = self.listAttrs(data, 'code', etag='Language') - self.mediaTypes = [] - else: - self.languageCodes = [] - self.mediaTypes = self.findItems(data, cls=AgentMediaType) + return self.listAttrs(self._data, 'code', etag='Language') + return [] + + @cached_data_property + def mediaTypes(self): + if 'mediaType' not in self._initpath: + return self.findItems(self._data, cls=AgentMediaType) + return [] @property @deprecated('use "languageCodes" instead') From 6ad68ce611a8858c781e9a41a73b84c323cc793e Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Mon, 7 Apr 2025 16:53:00 +0000 Subject: [PATCH 20/33] refactor: Delegate cache invalidation logic to the reload function instead of _loadData --- plexapi/audio.py | 1 - plexapi/base.py | 17 +++++++++++++---- plexapi/client.py | 3 +-- plexapi/collection.py | 2 +- plexapi/library.py | 26 +++++++------------------- plexapi/media.py | 35 +++++++++++++---------------------- plexapi/myplex.py | 18 +++++++----------- plexapi/photo.py | 2 -- plexapi/playlist.py | 1 - plexapi/playqueue.py | 2 +- plexapi/server.py | 19 +++++++++---------- plexapi/settings.py | 2 -- plexapi/sonos.py | 1 - plexapi/sync.py | 4 ++-- plexapi/video.py | 1 - 15 files changed, 54 insertions(+), 80 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index ff4655145..5921ed6f6 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -59,7 +59,6 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') diff --git a/plexapi/base.py b/plexapi/base.py index 0a99e335e..4d192e1dc 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -470,7 +470,7 @@ def _reload(self, key=None, _overwriteNone=True, **kwargs): self._initpath = key data = self._server.query(key) self._overwriteNone = _overwriteNone - self._loadData(data[0]) + self._invalidateCacheAndLoadData(data[0]) self._overwriteNone = True return self @@ -540,7 +540,7 @@ def _invalidateCachedProperties(self): if prop_name in self.__dict__: del self.__dict__[prop_name] - def _loadData(self, data): + def _invalidateCacheAndLoadData(self, data): """Load attribute values from Plex XML response and invalidate cached properties.""" old_data_id = id(getattr(self, '_data', None)) self._data = data @@ -549,6 +549,12 @@ def _loadData(self, data): if id(data) != old_data_id: self._invalidateCachedProperties() + self._loadData(data) + + def _loadData(self, data): + """ Load attribute values from Plex XML response. """ + raise NotImplementedError('Abstract method not implemented.') + @property def _searchType(self): return self.TYPE @@ -813,6 +819,7 @@ class Playable: """ def _loadData(self, data): + """ Load attribute values from Plex XML response. """ self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue @@ -994,6 +1001,7 @@ class PlexSession(object): """ def _loadData(self, data): + """ Load attribute values from Plex XML response. """ self.live = utils.cast(bool, data.attrib.get('live', '0')) self.player = self.findItem(data, etag='Player') self.session = self.findItem(data, etag='Session') @@ -1037,7 +1045,7 @@ def _reload(self, _autoReload=False, **kwargs): data = self._server.query(key) for elem in data: if elem.attrib.get('sessionKey') == str(self.sessionKey): - self._loadData(elem) + self._invalidateCacheAndLoadData(elem) break return self @@ -1070,6 +1078,7 @@ class PlexHistory(object): """ def _loadData(self, data): + """ Load attribute values from Plex XML response. """ self.accountID = utils.cast(int, data.attrib.get('accountID')) self.deviceID = utils.cast(int, data.attrib.get('deviceID')) self.historyKey = data.attrib.get('historyKey') @@ -1173,7 +1182,7 @@ def extend( setattr(self, key, getattr(__iterable, key)) def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.allowSync = utils.cast(int, data.attrib.get('allowSync')) self.augmentationKey = data.attrib.get('augmentationKey') self.identifier = data.attrib.get('identifier') diff --git a/plexapi/client.py b/plexapi/client.py index 424ca2e85..b3ec22eeb 100644 --- a/plexapi/client.py +++ b/plexapi/client.py @@ -124,7 +124,6 @@ def reload(self): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.deviceClass = data.attrib.get('deviceClass') self.machineIdentifier = data.attrib.get('machineIdentifier') self.product = data.attrib.get('product') @@ -606,7 +605,7 @@ class ClientTimeline(PlexObject): key = 'timeline/poll' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.address = data.attrib.get('address') self.audioStreamId = utils.cast(int, data.attrib.get('audioStreamId')) self.autoPlay = utils.cast(bool, data.attrib.get('autoPlay')) diff --git a/plexapi/collection.py b/plexapi/collection.py index dc2c0caa5..2a246c2c8 100644 --- a/plexapi/collection.py +++ b/plexapi/collection.py @@ -69,7 +69,7 @@ class Collection( TYPE = 'collection' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') diff --git a/plexapi/library.py b/plexapi/library.py index 75536ec2f..20265f692 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -39,7 +39,7 @@ class Library(PlexObject): key = '/library' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.identifier = data.attrib.get('identifier') self.mediaTagVersion = data.attrib.get('mediaTagVersion') self.title1 = data.attrib.get('title1') @@ -432,7 +432,7 @@ class LibrarySection(PlexObject): """ def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.agent = data.attrib.get('agent') self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) self.art = data.attrib.get('art') @@ -2169,7 +2169,6 @@ class LibraryTimeline(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.size = utils.cast(int, data.attrib.get('size')) self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) self.art = data.attrib.get('art') @@ -2198,7 +2197,6 @@ class Location(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.id = utils.cast(int, data.attrib.get('id')) self.path = data.attrib.get('path') @@ -2224,7 +2222,6 @@ class Hub(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.context = data.attrib.get('context') self.hubKey = data.attrib.get('hubKey') self.hubIdentifier = data.attrib.get('hubIdentifier') @@ -2286,7 +2283,6 @@ class LibraryMediaTag(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.count = utils.cast(int, data.attrib.get('count')) self.filter = data.attrib.get('filter') self.id = utils.cast(int, data.attrib.get('id')) @@ -2675,7 +2671,7 @@ def __repr__(self): return f"<{':'.join([p for p in [self.__class__.__name__, _type] if p])}>" def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.active = utils.cast(bool, data.attrib.get('active', '0')) self.key = data.attrib.get('key') self.title = data.attrib.get('title') @@ -2873,7 +2869,7 @@ class FilteringFilter(PlexObject): TAG = 'Filter' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.filter = data.attrib.get('filter') self.filterType = data.attrib.get('filterType') self.key = data.attrib.get('key') @@ -2899,7 +2895,6 @@ class FilteringSort(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.active = utils.cast(bool, data.attrib.get('active', '0')) self.activeDirection = data.attrib.get('activeDirection') self.default = data.attrib.get('default') @@ -2924,7 +2919,6 @@ class FilteringField(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.key = data.attrib.get('key') self.title = data.attrib.get('title') self.type = data.attrib.get('type') @@ -2947,7 +2941,6 @@ def __repr__(self): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.type = data.attrib.get('type') @cached_data_property @@ -2967,7 +2960,6 @@ class FilteringOperator(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.key = data.attrib.get('key') self.title = data.attrib.get('title') @@ -2990,7 +2982,6 @@ class FilterChoice(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.fastKey = data.attrib.get('fastKey') self.key = data.attrib.get('key') self.thumb = data.attrib.get('thumb') @@ -3020,7 +3011,6 @@ class ManagedHub(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.deletable = utils.cast(bool, data.attrib.get('deletable', True)) self.homeVisibility = data.attrib.get('homeVisibility', 'none') self.identifier = data.attrib.get('identifier') @@ -3144,7 +3134,6 @@ class Folder(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.key = data.attrib.get('key') self.title = data.attrib.get('title') @@ -3185,7 +3174,6 @@ class FirstCharacter(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.key = data.attrib.get('key') self.size = data.attrib.get('size') self.title = data.attrib.get('title') @@ -3206,7 +3194,7 @@ class Path(PlexObject): TAG = 'Path' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.home = utils.cast(bool, data.attrib.get('home')) self.key = data.attrib.get('key') self.network = utils.cast(bool, data.attrib.get('network')) @@ -3236,7 +3224,7 @@ class File(PlexObject): TAG = 'File' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.key = data.attrib.get('key') self.path = data.attrib.get('path') self.title = data.attrib.get('title') @@ -3285,7 +3273,7 @@ class Common(PlexObject): TAG = 'Common' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.contentRating = data.attrib.get('contentRating') self.editionTitle = data.attrib.get('editionTitle') self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey')) diff --git a/plexapi/media.py b/plexapi/media.py index 0b15aaf09..cc721b6fd 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -51,7 +51,6 @@ class Media(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.aspectRatio = utils.cast(float, data.attrib.get('aspectRatio')) self.audioChannels = utils.cast(int, data.attrib.get('audioChannels')) self.audioCodec = data.attrib.get('audioCodec') @@ -141,7 +140,6 @@ class MediaPart(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.accessible = utils.cast(bool, data.attrib.get('accessible')) self.audioProfile = data.attrib.get('audioProfile') self.container = data.attrib.get('container') @@ -271,7 +269,6 @@ class MediaPartStream(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.bitrate = utils.cast(int, data.attrib.get('bitrate')) self.codec = data.attrib.get('codec') self.decision = data.attrib.get('decision') @@ -526,7 +523,7 @@ class Session(PlexObject): TAG = 'Session' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.id = data.attrib.get('id') self.bandwidth = utils.cast(int, data.attrib.get('bandwidth')) self.location = data.attrib.get('location') @@ -573,7 +570,6 @@ class TranscodeSession(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.audioChannels = utils.cast(int, data.attrib.get('audioChannels')) self.audioCodec = data.attrib.get('audioCodec') self.audioDecision = data.attrib.get('audioDecision') @@ -614,7 +610,7 @@ class TranscodeJob(PlexObject): TAG = 'TranscodeJob' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.generatorID = data.attrib.get('generatorID') self.key = data.attrib.get('key') self.progress = data.attrib.get('progress') @@ -633,7 +629,7 @@ class Optimized(PlexObject): TAG = 'Item' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.id = data.attrib.get('id') self.composite = data.attrib.get('composite') self.title = data.attrib.get('title') @@ -671,7 +667,7 @@ class Conversion(PlexObject): TAG = 'Video' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.addedAt = data.attrib.get('addedAt') self.art = data.attrib.get('art') self.chapterSource = data.attrib.get('chapterSource') @@ -747,7 +743,6 @@ def __str__(self): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.filter = data.attrib.get('filter') self.id = utils.cast(int, data.attrib.get('id')) self.key = data.attrib.get('key') @@ -958,7 +953,6 @@ class Guid(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.id = data.attrib.get('id') @@ -976,7 +970,6 @@ class Image(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.alt = data.attrib.get('alt') self.type = data.attrib.get('type') self.url = data.attrib.get('url') @@ -998,7 +991,6 @@ class Rating(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.image = data.attrib.get('image') self.type = data.attrib.get('type') self.value = utils.cast(float, data.attrib.get('value')) @@ -1021,7 +1013,7 @@ class Review(PlexObject): TAG = 'Review' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.filter = data.attrib.get('filter') self.id = utils.cast(int, data.attrib.get('id', 0)) self.image = data.attrib.get('image') @@ -1046,7 +1038,6 @@ class UltraBlurColors(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.bottomLeft = data.attrib.get('bottomLeft') self.bottomRight = data.attrib.get('bottomRight') self.topLeft = data.attrib.get('topLeft') @@ -1067,7 +1058,7 @@ class BaseResource(PlexObject): """ def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.key = data.attrib.get('key') self.provider = data.attrib.get('provider') self.ratingKey = data.attrib.get('ratingKey') @@ -1142,7 +1133,7 @@ def __repr__(self): return f"<{':'.join([self.__class__.__name__, name, offsets])}>" def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.end = utils.cast(int, data.attrib.get('endTimeOffset')) self.filter = data.attrib.get('filter') self.id = utils.cast(int, data.attrib.get('id', 0)) @@ -1176,7 +1167,7 @@ def __repr__(self): return f"<{':'.join([self.__class__.__name__, name, offsets])}>" def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.end = utils.cast(int, data.attrib.get('endTimeOffset')) self.final = utils.cast(bool, data.attrib.get('final')) self.id = utils.cast(int, data.attrib.get('id')) @@ -1210,7 +1201,7 @@ class Field(PlexObject): TAG = 'Field' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.locked = utils.cast(bool, data.attrib.get('locked')) self.name = data.attrib.get('name') @@ -1230,7 +1221,7 @@ def __repr__(self): return f"<{':'.join([p for p in [self.__class__.__name__, name, score] if p])}>" def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.guid = data.attrib.get('guid') self.lifespanEnded = data.attrib.get('lifespanEnded') self.name = data.attrib.get('name') @@ -1252,7 +1243,7 @@ def __repr__(self): return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>" def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.hasAttribution = data.attrib.get('hasAttribution') self.hasPrefs = data.attrib.get('hasPrefs') self.identifier = data.attrib.get('identifier') @@ -1300,7 +1291,7 @@ def __repr__(self): return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>" def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.mediaType = utils.cast(int, data.attrib.get('mediaType')) self.name = data.attrib.get('name') @@ -1338,7 +1329,7 @@ def __repr__(self): return f'<{self.__class__.__name__}:{self.platform}:{self.offerType}>' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.country = data.attrib.get('country') self.offerType = data.attrib.get('offerType') self.platform = data.attrib.get('platform') diff --git a/plexapi/myplex.py b/plexapi/myplex.py index ed9497e79..17ca45540 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -144,7 +144,6 @@ def signout(self): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self._token = logfilter.add_secret(data.attrib.get('authToken')) self._webhooks = [] @@ -1215,7 +1214,6 @@ class MyPlexUser(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.friend = self._initpath == self.key self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload')) self.allowChannels = utils.cast(bool, data.attrib.get('allowChannels')) @@ -1295,7 +1293,6 @@ class MyPlexInvite(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) self.email = data.attrib.get('email') self.friend = utils.cast(bool, data.attrib.get('friend')) @@ -1329,7 +1326,7 @@ class Section(PlexObject): TAG = 'Section' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.id = utils.cast(int, data.attrib.get('id')) self.key = utils.cast(int, data.attrib.get('key')) self.shared = utils.cast(bool, data.attrib.get('shared', '0')) @@ -1368,7 +1365,6 @@ class MyPlexServerShare(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.id = utils.cast(int, data.attrib.get('id')) self.accountID = utils.cast(int, data.attrib.get('accountID')) self.serverId = utils.cast(int, data.attrib.get('serverId')) @@ -1452,7 +1448,7 @@ class MyPlexResource(PlexObject): DEFAULT_SCHEME_ORDER = ['https', 'http'] def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.accessToken = logfilter.add_secret(data.attrib.get('accessToken')) self.clientIdentifier = data.attrib.get('clientIdentifier') self.createdAt = utils.toDatetime(data.attrib.get('createdAt'), "%Y-%m-%dT%H:%M:%SZ") @@ -1573,7 +1569,7 @@ class ResourceConnection(PlexObject): TAG = 'connection' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.address = data.attrib.get('address') self.ipv6 = utils.cast(bool, data.attrib.get('IPv6')) self.local = utils.cast(bool, data.attrib.get('local')) @@ -1616,7 +1612,7 @@ class MyPlexDevice(PlexObject): key = 'https://plex.tv/devices.xml' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.name = data.attrib.get('name') self.publicAddress = data.attrib.get('publicAddress') self.product = data.attrib.get('product') @@ -1960,7 +1956,7 @@ class AccountOptOut(PlexObject): CHOICES = {'opt_in', 'opt_out', 'opt_out_managed'} def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.key = data.attrib.get('key') self.value = data.attrib.get('value') @@ -2019,7 +2015,7 @@ def __repr__(self): return f'<{self.__class__.__name__}:{self.ratingKey}>' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.lastViewedAt = utils.toDatetime(data.attrib.get('lastViewedAt')) self.ratingKey = data.attrib.get('ratingKey') self.type = data.attrib.get('type') @@ -2049,7 +2045,7 @@ class GeoLocation(PlexObject): TAG = 'location' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.city = data.attrib.get('city') self.code = data.attrib.get('code') self.continentCode = data.attrib.get('continent_code') diff --git a/plexapi/photo.py b/plexapi/photo.py index ea99aad70..3946e9ae3 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -53,7 +53,6 @@ class Photoalbum( def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.composite = data.attrib.get('composite') @@ -208,7 +207,6 @@ class Photo( def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) Playable._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.createdAtAccuracy = data.attrib.get('createdAtAccuracy') diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 51924497c..b9af7fc72 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -53,7 +53,6 @@ class Playlist( def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) Playable._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.allowSync = utils.cast(bool, data.attrib.get('allowSync')) diff --git a/plexapi/playqueue.py b/plexapi/playqueue.py index e8874741a..e2f531892 100644 --- a/plexapi/playqueue.py +++ b/plexapi/playqueue.py @@ -36,7 +36,7 @@ class PlayQueue(PlexObject): TYPE = "playqueue" def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.identifier = data.attrib.get("identifier") self.mediaTagPrefix = data.attrib.get("mediaTagPrefix") self.mediaTagVersion = utils.cast(int, data.attrib.get("mediaTagVersion")) diff --git a/plexapi/server.py b/plexapi/server.py index 394e0cdb7..48d442110 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -118,7 +118,6 @@ def __init__(self, baseurl=None, token=None, session=None, timeout=None): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.allowCameraUpload = utils.cast(bool, data.attrib.get('allowCameraUpload')) self.allowChannelAccess = utils.cast(bool, data.attrib.get('allowChannelAccess')) self.allowMediaDeletion = utils.cast(bool, data.attrib.get('allowMediaDeletion')) @@ -1093,7 +1092,7 @@ class Account(PlexObject): key = '/myplex/account' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.authToken = data.attrib.get('authToken') self.username = data.attrib.get('username') self.mappingState = data.attrib.get('mappingState') @@ -1114,7 +1113,7 @@ class Activity(PlexObject): key = '/activities' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.cancellable = utils.cast(bool, data.attrib.get('cancellable')) self.progress = utils.cast(int, data.attrib.get('progress')) self.title = data.attrib.get('title') @@ -1129,7 +1128,7 @@ class Release(PlexObject): key = '/updater/status' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.download_key = data.attrib.get('key') self.version = data.attrib.get('version') self.added = data.attrib.get('added') @@ -1155,7 +1154,7 @@ class SystemAccount(PlexObject): TAG = 'Account' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.autoSelectAudio = utils.cast(bool, data.attrib.get('autoSelectAudio')) self.defaultAudioLanguage = data.attrib.get('defaultAudioLanguage') self.defaultSubtitleLanguage = data.attrib.get('defaultSubtitleLanguage') @@ -1184,7 +1183,7 @@ class SystemDevice(PlexObject): TAG = 'Device' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.clientIdentifier = data.attrib.get('clientIdentifier') self.createdAt = utils.toDatetime(data.attrib.get('createdAt')) self.id = utils.cast(int, data.attrib.get('id')) @@ -1210,7 +1209,7 @@ class StatisticsBandwidth(PlexObject): TAG = 'StatisticsBandwidth' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.accountID = utils.cast(int, data.attrib.get('accountID')) self.at = utils.toDatetime(data.attrib.get('at')) self.bytes = utils.cast(int, data.attrib.get('bytes')) @@ -1252,7 +1251,7 @@ class StatisticsResources(PlexObject): TAG = 'StatisticsResources' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.at = utils.toDatetime(data.attrib.get('at')) self.hostCpuUtilization = utils.cast(float, data.attrib.get('hostCpuUtilization')) self.hostMemoryUtilization = utils.cast(float, data.attrib.get('hostMemoryUtilization')) @@ -1280,7 +1279,7 @@ class ButlerTask(PlexObject): TAG = 'ButlerTask' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.description = data.attrib.get('description') self.enabled = utils.cast(bool, data.attrib.get('enabled')) self.interval = utils.cast(int, data.attrib.get('interval')) @@ -1302,7 +1301,7 @@ def __repr__(self): return f"<{self.__class__.__name__}:{self.machineIdentifier}>" def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.claimed = utils.cast(bool, data.attrib.get('claimed')) self.machineIdentifier = data.attrib.get('machineIdentifier') self.version = data.attrib.get('version') diff --git a/plexapi/settings.py b/plexapi/settings.py index 37f7bd35b..228dafaf3 100644 --- a/plexapi/settings.py +++ b/plexapi/settings.py @@ -34,7 +34,6 @@ def __setattr__(self, attr, value): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) for elem in data: id = utils.lowerFirst(elem.attrib['id']) if id in self._settings: @@ -113,7 +112,6 @@ class Setting(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.type = data.attrib.get('type') self.advanced = utils.cast(bool, data.attrib.get('advanced')) self.default = self._cast(data.attrib.get('default')) diff --git a/plexapi/sonos.py b/plexapi/sonos.py index 0f387c119..e49fd5981 100644 --- a/plexapi/sonos.py +++ b/plexapi/sonos.py @@ -48,7 +48,6 @@ class PlexSonosClient(PlexClient): """ def __init__(self, account, data, timeout=None): - PlexObject._loadData(self, data) self.deviceClass = data.attrib.get("deviceClass") self.machineIdentifier = data.attrib.get("machineIdentifier") self.product = data.attrib.get("product") diff --git a/plexapi/sync.py b/plexapi/sync.py index 0c3e5c4ff..3b00653d2 100644 --- a/plexapi/sync.py +++ b/plexapi/sync.py @@ -63,7 +63,7 @@ def __init__(self, server, data, initpath=None, clientIdentifier=None): self.clientIdentifier = clientIdentifier def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.id = plexapi.utils.cast(int, data.attrib.get('id')) self.version = plexapi.utils.cast(int, data.attrib.get('version')) self.rootTitle = data.attrib.get('rootTitle') @@ -118,7 +118,7 @@ class SyncList(PlexObject): TAG = 'SyncList' def _loadData(self, data): - PlexObject._loadData(self, data) + """ Load attribute values from Plex XML response. """ self.clientId = data.attrib.get('clientIdentifier') self.items = [] diff --git a/plexapi/video.py b/plexapi/video.py index 6e9b4727b..e105c302d 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -48,7 +48,6 @@ class Video(PlexPartialObject, PlayedUnplayedMixin): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - PlexObject._loadData(self, data) self.addedAt = utils.toDatetime(data.attrib.get('addedAt')) self.art = data.attrib.get('art') self.artBlurHash = data.attrib.get('artBlurHash') From 90bf0fd7eec0aa40ca2c3a865379db61635f95ef Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Mon, 7 Apr 2025 16:58:26 +0000 Subject: [PATCH 21/33] style: Removed unecessary explicit object inheritence (implicit since Python3) --- plexapi/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 4d192e1dc..50ad5585f 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -987,7 +987,7 @@ def updateTimeline(self, time, state='stopped', duration=None): return self -class PlexSession(object): +class PlexSession: """ This is a general place to store functions specific to media that is a Plex Session. Attributes: @@ -1067,7 +1067,7 @@ def stop(self, reason=''): return self._server.query(key, params=params) -class PlexHistory(object): +class PlexHistory: """ This is a general place to store functions specific to media that is a Plex history item. Attributes: From 8c917692c1184c1eaf5b5e20a205e6e71070605b Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Mon, 7 Apr 2025 17:00:38 +0000 Subject: [PATCH 22/33] perf: Lazy load expensive attributes in PlexSession --- plexapi/base.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 50ad5585f..f563d019c 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -1013,10 +1013,33 @@ def _loadData(self, data): self._userId = utils.cast(int, user.attrib.get('id')) # For backwards compatibility - self.players = [self.player] if self.player else [] - self.sessions = [self.session] if self.session else [] - self.transcodeSessions = [self.transcodeSession] if self.transcodeSession else [] self.usernames = [self._username] if self._username else [] + # `players`, `sessions`, and `transcodeSessions` are returned with properties + # to support lazy loading. See PR #1510 + + @cached_data_property + def player(self): + return self.findItem(self.data, etag='Player') + + @cached_data_property + def session(self): + return self.findItem(self.data, etag='Session') + + @cached_data_property + def transcodeSession(self): + return self.findItem(self.data, etag='TranscodeSession') + + @property + def players(self): + return [self.player] if self.player else [] + + @property + def sessions(self): + return [self.session] if self.session else [] + + @property + def transcodeSessions(self): + return [self.transcodeSession] if self.transcodeSession else [] @cached_property def user(self): From ca0b1c874cfef43c01d97824fafe83e67ecbe2f5 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Mon, 7 Apr 2025 17:01:11 +0000 Subject: [PATCH 23/33] docs: Make it clearer that Playable, PlexSession, and PlexHistory are mixins --- plexapi/base.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index f563d019c..3387eeb3a 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -809,10 +809,12 @@ def playQueue(self, *args, **kwargs): class Playable: - """ This is a general place to store functions specific to media that is Playable. + """ This is a mixin to store functions specific to media that is Playable. Things were getting mixed up a bit when dealing with Shows, Season, Artists, Albums which are all not playable. + This class + Attributes: playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items). playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items). @@ -988,7 +990,7 @@ def updateTimeline(self, time, state='stopped', duration=None): class PlexSession: - """ This is a general place to store functions specific to media that is a Plex Session. + """ This is a mixin to store functions specific to media that is a Plex Session. Attributes: live (bool): True if this is a live tv session. @@ -1091,7 +1093,7 @@ def stop(self, reason=''): class PlexHistory: - """ This is a general place to store functions specific to media that is a Plex history item. + """ This is a mixin to store functions specific to media that is a Plex history item. Attributes: accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID. From 88e165f5edb22b1d5b6528993b6989babfa2f64b Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Mon, 7 Apr 2025 17:07:09 +0000 Subject: [PATCH 24/33] fix: Handle special reload cache invalidation logic for MyPlexAccount and PlexClient --- plexapi/client.py | 2 +- plexapi/myplex.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plexapi/client.py b/plexapi/client.py index b3ec22eeb..08fa518ad 100644 --- a/plexapi/client.py +++ b/plexapi/client.py @@ -115,7 +115,7 @@ def connect(self, timeout=None): ) else: client = data[0] - self._loadData(client) + self._invalidateCacheAndLoadData(client) return self def reload(self): diff --git a/plexapi/myplex.py b/plexapi/myplex.py index 17ca45540..0e9c113e2 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -223,7 +223,7 @@ def authenticationToken(self): def _reload(self, key=None, **kwargs): """ Perform the actual reload. """ data = self.query(self.key) - self._loadData(data) + self._invalidateCacheAndLoadData(data) return self def _headers(self, **kwargs): From 0229729e24607a6520077003849da2569129c476 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Mon, 7 Apr 2025 18:54:02 +0000 Subject: [PATCH 25/33] refactor: Unused import --- plexapi/sonos.py | 1 - 1 file changed, 1 deletion(-) diff --git a/plexapi/sonos.py b/plexapi/sonos.py index e49fd5981..1b61fd568 100644 --- a/plexapi/sonos.py +++ b/plexapi/sonos.py @@ -5,7 +5,6 @@ from plexapi.client import PlexClient from plexapi.exceptions import BadRequest from plexapi.playqueue import PlayQueue -from plexapi.base import PlexObject class PlexSonosClient(PlexClient): From 28bd9db869cb4ba1d0ca9f52bb3121ebd7df92f6 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Mon, 7 Apr 2025 18:54:58 +0000 Subject: [PATCH 26/33] style: Reorder functions for more accurate order of operation --- plexapi/base.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 3387eeb3a..640ddb71d 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -532,14 +532,6 @@ def _castAttrValue(self, op, query, value): return float(value) return value - def _invalidateCachedProperties(self): - """Invalidate all cached data property values.""" - cached_props = getattr(self.__class__, '_cached_data_properties', set()) - - for prop_name in cached_props: - if prop_name in self.__dict__: - del self.__dict__[prop_name] - def _invalidateCacheAndLoadData(self, data): """Load attribute values from Plex XML response and invalidate cached properties.""" old_data_id = id(getattr(self, '_data', None)) @@ -551,6 +543,14 @@ def _invalidateCacheAndLoadData(self, data): self._loadData(data) + def _invalidateCachedProperties(self): + """Invalidate all cached data property values.""" + cached_props = getattr(self.__class__, '_cached_data_properties', set()) + + for prop_name in cached_props: + if prop_name in self.__dict__: + del self.__dict__[prop_name] + def _loadData(self, data): """ Load attribute values from Plex XML response. """ raise NotImplementedError('Abstract method not implemented.') From 81c16b761a0dea0cecd5648d00961985086223c4 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Mon, 7 Apr 2025 18:55:08 +0000 Subject: [PATCH 27/33] docs: Removed typo --- plexapi/base.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index 640ddb71d..df92fb2d7 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -813,8 +813,6 @@ class Playable: Things were getting mixed up a bit when dealing with Shows, Season, Artists, Albums which are all not playable. - This class - Attributes: playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items). playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items). @@ -1005,10 +1003,7 @@ class PlexSession: def _loadData(self, data): """ Load attribute values from Plex XML response. """ self.live = utils.cast(bool, data.attrib.get('live', '0')) - self.player = self.findItem(data, etag='Player') - self.session = self.findItem(data, etag='Session') self.sessionKey = utils.cast(int, data.attrib.get('sessionKey')) - self.transcodeSession = self.findItem(data, etag='TranscodeSession') user = data.find('User') self._username = user.attrib.get('title') From 53364a9041680f1a126ab5a7bc9808c869acadb7 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Tue, 8 Apr 2025 18:47:55 +0000 Subject: [PATCH 28/33] refactor: Fixed all flake8 unused import warning --- plexapi/audio.py | 2 +- plexapi/collection.py | 2 +- plexapi/photo.py | 2 +- plexapi/playlist.py | 2 +- plexapi/video.py | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plexapi/audio.py b/plexapi/audio.py index 5921ed6f6..3bc6f514e 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional, TypeVar from plexapi import media, utils -from plexapi.base import Playable, PlexObject, PlexPartialObject, PlexHistory, PlexSession, cached_data_property +from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession, cached_data_property from plexapi.exceptions import BadRequest from plexapi.mixins import ( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, diff --git a/plexapi/collection.py b/plexapi/collection.py index 2a246c2c8..17e4524bb 100644 --- a/plexapi/collection.py +++ b/plexapi/collection.py @@ -3,7 +3,7 @@ from urllib.parse import quote_plus from plexapi import media, utils -from plexapi.base import PlexObject, PlexPartialObject, cached_data_property +from plexapi.base import PlexPartialObject, cached_data_property from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection, ManagedHub from plexapi.mixins import ( diff --git a/plexapi/photo.py b/plexapi/photo.py index 3946e9ae3..e7c7239e8 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -4,7 +4,7 @@ from urllib.parse import quote_plus from plexapi import media, utils, video -from plexapi.base import Playable, PlexObject, PlexPartialObject, PlexSession, cached_data_property +from plexapi.base import Playable, PlexPartialObject, PlexSession, cached_data_property from plexapi.exceptions import BadRequest from plexapi.mixins import ( RatingMixin, diff --git a/plexapi/playlist.py b/plexapi/playlist.py index b9af7fc72..0662e6165 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -5,7 +5,7 @@ from urllib.parse import quote_plus, unquote from plexapi import media, utils -from plexapi.base import Playable, PlexObject, PlexPartialObject, cached_data_property +from plexapi.base import Playable, PlexPartialObject, cached_data_property from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection, MusicSection from plexapi.mixins import SmartFilterMixin, ArtMixin, PosterMixin, PlaylistEditMixins diff --git a/plexapi/video.py b/plexapi/video.py index e105c302d..1ca73e93c 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -5,7 +5,7 @@ from urllib.parse import quote_plus from plexapi import media, utils -from plexapi.base import Playable, PlexObject, PlexPartialObject, PlexHistory, PlexSession, cached_data_property +from plexapi.base import Playable, PlexPartialObject, PlexHistory, PlexSession, cached_data_property from plexapi.exceptions import BadRequest from plexapi.mixins import ( AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin, From 65303c2a3b01be735dc1dd240231ba2f4bd40302 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Tue, 8 Apr 2025 19:05:16 +0000 Subject: [PATCH 29/33] fix: Invalidate the cache after all PUT queries in the PlayQueue object --- plexapi/playqueue.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plexapi/playqueue.py b/plexapi/playqueue.py index e2f531892..8875ef076 100644 --- a/plexapi/playqueue.py +++ b/plexapi/playqueue.py @@ -257,7 +257,7 @@ def addItem(self, item, playNext=False, refresh=True): path = f"/playQueues/{self.playQueueID}{utils.joinArgs(args)}" data = self._server.query(path, method=self._server._session.put) - self._loadData(data) + self._invalidateCacheAndLoadData(data) return self def moveItem(self, item, after=None, refresh=True): @@ -286,7 +286,7 @@ def moveItem(self, item, after=None, refresh=True): path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}/move{utils.joinArgs(args)}" data = self._server.query(path, method=self._server._session.put) - self._loadData(data) + self._invalidateCacheAndLoadData(data) return self def removeItem(self, item, refresh=True): @@ -304,19 +304,19 @@ def removeItem(self, item, refresh=True): path = f"/playQueues/{self.playQueueID}/items/{item.playQueueItemID}" data = self._server.query(path, method=self._server._session.delete) - self._loadData(data) + self._invalidateCacheAndLoadData(data) return self def clear(self): """Remove all items from the PlayQueue.""" path = f"/playQueues/{self.playQueueID}/items" data = self._server.query(path, method=self._server._session.delete) - self._loadData(data) + self._invalidateCacheAndLoadData(data) return self def refresh(self): """Refresh the PlayQueue from the Plex server.""" path = f"/playQueues/{self.playQueueID}" data = self._server.query(path, method=self._server._session.get) - self._loadData(data) + self._invalidateCacheAndLoadData(data) return self From 03203602cd2a1297e4fa8206264455afef400c24 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Wed, 9 Apr 2025 03:28:17 +0000 Subject: [PATCH 30/33] refactor: Call loadData with invalidation in the Settings class for future compatability --- plexapi/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/settings.py b/plexapi/settings.py index 228dafaf3..4e016e32f 100644 --- a/plexapi/settings.py +++ b/plexapi/settings.py @@ -37,7 +37,7 @@ def _loadData(self, data): for elem in data: id = utils.lowerFirst(elem.attrib['id']) if id in self._settings: - self._settings[id]._loadData(elem) + self._settings[id]._invalidateCacheAndLoadData(elem) continue self._settings[id] = Setting(self._server, elem, self._initpath) From a06a43f31f2b66227d7779ca273bcc0874faaa07 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Wed, 9 Apr 2025 04:13:29 +0000 Subject: [PATCH 31/33] refactor: Better align the items and reload functions with the internal caching mechanism --- plexapi/library.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plexapi/library.py b/plexapi/library.py index 20265f692..19a7954f6 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -2235,6 +2235,12 @@ def _loadData(self, data): @cached_data_property def items(self): + if self.more and self.key: # If there are more items to load, fetch them + items = self.fetchItems(self.key) + self.more = False + self.size = len(items) + return items + # Otherwise, all the data is in the initial _data XML response return self.findItems(self._data) def __len__(self): @@ -2242,10 +2248,7 @@ def __len__(self): def reload(self): """ Reloads the hub to fetch all items in the hub. """ - if self.more and self.key: - self.items = self.fetchItems(self.key) - self.more = False - self.size = len(self.items) + self._invalidateCachedProperties() def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this hub belongs to. From c98a9d167d91cdd7d7e2cb1d95e7f3498b496d6f Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Wed, 9 Apr 2025 04:29:11 +0000 Subject: [PATCH 32/33] fix: Reset the state of Hub pagination metadata on reloads --- plexapi/library.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plexapi/library.py b/plexapi/library.py index 19a7954f6..ccf483b96 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -2249,6 +2249,9 @@ def __len__(self): def reload(self): """ Reloads the hub to fetch all items in the hub. """ self._invalidateCachedProperties() + if self._data is not None: + self.more = utils.cast(bool, self._data.attrib.get('more')) + self.size = utils.cast(int, self._data.attrib.get('size')) def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this hub belongs to. From e3fc9c8eb1edc7627d9869fe1bfd0025c64f3a87 Mon Sep 17 00:00:00 2001 From: Elias Benbourenane Date: Wed, 9 Apr 2025 04:33:39 +0000 Subject: [PATCH 33/33] docs: Updated docstring for `Hub.reload(...)` changes --- plexapi/library.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plexapi/library.py b/plexapi/library.py index ccf483b96..05c177524 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -2247,7 +2247,7 @@ def __len__(self): return self.size def reload(self): - """ Reloads the hub to fetch all items in the hub. """ + """ Delete cached data to allow reloading of hub items. """ self._invalidateCachedProperties() if self._data is not None: self.more = utils.cast(bool, self._data.attrib.get('more'))