diff --git a/plexapi/audio.py b/plexapi/audio.py index 05d38a9c7..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, 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,11 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = 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 +72,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 +84,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 +213,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 +390,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 +402,41 @@ 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) + + @cached_data_property + def ultraBlurColors(self): + return self.findItem(self._data, media.UltraBlurColors) + def __iter__(self): for track in self.tracks(): yield track @@ -495,11 +554,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 +563,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 +578,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/base.py b/plexapi/base.py index 675ac5d98..df92fb2d7 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: @@ -435,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 @@ -497,7 +532,27 @@ def _castAttrValue(self, op, query, value): return float(value) return value + 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 + + # If the data's object ID has changed, invalidate cached properties + if id(data) != old_data_id: + self._invalidateCachedProperties() + + 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.') @property @@ -754,7 +809,7 @@ 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. @@ -764,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 @@ -931,8 +987,8 @@ def updateTimeline(self, time, state='stopped', duration=None): return self -class PlexSession(object): - """ This is a general place to store functions specific to media that is a Plex Session. +class PlexSession: + """ 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. @@ -945,21 +1001,42 @@ 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') 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') 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): @@ -988,7 +1065,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 @@ -1010,8 +1087,8 @@ def stop(self, reason=''): return self._server.query(key, params=params) -class PlexHistory(object): - """ This is a general place to store functions specific to media that is a Plex history item. +class PlexHistory: + """ 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. @@ -1021,6 +1098,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') @@ -1124,7 +1202,7 @@ def extend( setattr(self, key, getattr(__iterable, key)) def _loadData(self, data): - self._data = 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 3d89e3dc6..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): @@ -124,7 +124,6 @@ def reload(self): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = 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): - self._data = 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 63ea83730..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 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 + """ 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') @@ -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') @@ -105,13 +102,28 @@ 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 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) + + @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/library.py b/plexapi/library.py index 93801a1d7..05c177524 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, @@ -39,7 +39,7 @@ class Library(PlexObject): key = '/library' def _loadData(self, data): - self._data = 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): - self._data = 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') @@ -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. """ @@ -543,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): @@ -2165,7 +2169,6 @@ class LibraryTimeline(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = 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,6 @@ class Location(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.id = utils.cast(int, data.attrib.get('id')) self.path = data.attrib.get('path') @@ -2220,11 +2222,9 @@ class Hub(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = 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,15 +2233,25 @@ def _loadData(self, data): self.type = data.attrib.get('type') self._section = None # cache for self.section + @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): return self.size 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) + """ 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')) + self.size = utils.cast(int, self._data.attrib.get('size')) def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this hub belongs to. @@ -2279,7 +2289,6 @@ class LibraryMediaTag(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = 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 +2677,25 @@ def __repr__(self): return f"<{':'.join([p for p in [self.__class__.__name__, _type] if p])}>" def _loadData(self, data): - self._data = data + """ Load attribute values from Plex XML response. """ 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 @@ -2863,7 +2875,7 @@ class FilteringFilter(PlexObject): TAG = 'Filter' def _loadData(self, data): - self._data = 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') @@ -2889,7 +2901,6 @@ class FilteringSort(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.active = utils.cast(bool, data.attrib.get('active', '0')) self.activeDirection = data.attrib.get('activeDirection') self.default = data.attrib.get('default') @@ -2914,7 +2925,6 @@ class FilteringField(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.key = data.attrib.get('key') self.title = data.attrib.get('title') self.type = data.attrib.get('type') @@ -2937,9 +2947,11 @@ def __repr__(self): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = 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): @@ -2976,7 +2988,6 @@ class FilterChoice(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.fastKey = data.attrib.get('fastKey') self.key = data.attrib.get('key') self.thumb = data.attrib.get('thumb') @@ -3006,7 +3017,6 @@ class ManagedHub(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.deletable = utils.cast(bool, data.attrib.get('deletable', True)) self.homeVisibility = data.attrib.get('homeVisibility', 'none') self.identifier = data.attrib.get('identifier') @@ -3170,7 +3180,6 @@ class FirstCharacter(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.key = data.attrib.get('key') self.size = data.attrib.get('size') self.title = data.attrib.get('title') @@ -3191,6 +3200,7 @@ class Path(PlexObject): TAG = 'Path' def _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')) @@ -3220,6 +3230,7 @@ class File(PlexObject): TAG = 'File' def _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') @@ -3268,41 +3279,83 @@ class Common(PlexObject): TAG = 'Common' def _loadData(self, data): - self._data = data - self.collections = self.findItems(data, media.Collection) + """ Load attribute values from Plex XML response. """ 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..cc721b6fd 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,6 @@ class Media(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = 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 +63,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 +85,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. """ @@ -138,7 +140,6 @@ class MediaPart(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.accessible = utils.cast(bool, data.attrib.get('accessible')) self.audioProfile = data.attrib.get('audioProfile') self.container = data.attrib.get('container') @@ -268,7 +269,6 @@ class MediaPartStream(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.bitrate = utils.cast(int, data.attrib.get('bitrate')) self.codec = data.attrib.get('codec') self.decision = data.attrib.get('decision') @@ -523,6 +523,7 @@ class Session(PlexObject): TAG = 'Session' def _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') @@ -569,7 +570,6 @@ class TranscodeSession(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.audioChannels = utils.cast(int, data.attrib.get('audioChannels')) self.audioCodec = data.attrib.get('audioCodec') self.audioDecision = data.attrib.get('audioDecision') @@ -610,7 +610,7 @@ class TranscodeJob(PlexObject): TAG = 'TranscodeJob' def _loadData(self, data): - self._data = 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') @@ -629,7 +629,7 @@ class Optimized(PlexObject): TAG = 'Item' def _loadData(self, data): - self._data = 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') @@ -667,7 +667,7 @@ class Conversion(PlexObject): TAG = 'Video' def _loadData(self, data): - self._data = 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') @@ -743,7 +743,6 @@ def __str__(self): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.filter = data.attrib.get('filter') self.id = utils.cast(int, data.attrib.get('id')) self.key = data.attrib.get('key') @@ -954,7 +953,6 @@ class Guid(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.id = data.attrib.get('id') @@ -972,7 +970,6 @@ class Image(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.alt = data.attrib.get('alt') self.type = data.attrib.get('type') self.url = data.attrib.get('url') @@ -994,7 +991,6 @@ class Rating(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.image = data.attrib.get('image') self.type = data.attrib.get('type') self.value = utils.cast(float, data.attrib.get('value')) @@ -1017,7 +1013,7 @@ class Review(PlexObject): TAG = 'Review' def _loadData(self, data): - self._data = 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') @@ -1042,7 +1038,6 @@ class UltraBlurColors(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self.bottomLeft = data.attrib.get('bottomLeft') self.bottomRight = data.attrib.get('bottomRight') self.topLeft = data.attrib.get('topLeft') @@ -1063,7 +1058,7 @@ class BaseResource(PlexObject): """ def _loadData(self, data): - self._data = 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') @@ -1138,7 +1133,7 @@ def __repr__(self): return f"<{':'.join([self.__class__.__name__, name, offsets])}>" def _loadData(self, data): - self._data = 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)) @@ -1172,7 +1167,7 @@ def __repr__(self): return f"<{':'.join([self.__class__.__name__, name, offsets])}>" def _loadData(self, data): - self._data = 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')) @@ -1206,7 +1201,7 @@ class Field(PlexObject): TAG = 'Field' def _loadData(self, data): - self._data = data + """ Load attribute values from Plex XML response. """ self.locked = utils.cast(bool, data.attrib.get('locked')) self.name = data.attrib.get('name') @@ -1226,7 +1221,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 + """ 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') @@ -1248,7 +1243,7 @@ def __repr__(self): return f"<{':'.join([p for p in [self.__class__.__name__, uid] if p])}>" def _loadData(self, data): - self._data = 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') @@ -1256,12 +1251,17 @@ def _loadData(self, data): self.primary = data.attrib.get('primary') self.shortIdentifier = self.identifier.rsplit('.', 1)[1] + @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') @@ -1291,10 +1291,14 @@ 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') + """ Load attribute values from Plex XML response. """ 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): @@ -1325,7 +1329,7 @@ def __repr__(self): return f'<{self.__class__.__name__}:{self.platform}:{self.offerType}>' def _loadData(self, data): - self._data = 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 448a2649a..0e9c113e2 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,6 @@ def signout(self): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = data self._token = logfilter.add_secret(data.attrib.get('authToken')) self._webhooks = [] @@ -185,7 +184,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 +199,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``. """ @@ -215,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): @@ -1206,7 +1214,6 @@ class MyPlexUser(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = 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 +1232,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 +1293,6 @@ class MyPlexInvite(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = 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 +1300,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 @@ -1314,7 +1326,7 @@ class Section(PlexObject): TAG = 'Section' def _loadData(self, data): - self._data = 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')) @@ -1353,7 +1365,6 @@ class MyPlexServerShare(PlexObject): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = 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')) @@ -1437,10 +1448,9 @@ class MyPlexResource(PlexObject): DEFAULT_SCHEME_ORDER = ['https', 'http'] def _loadData(self, data): - self._data = data + """ Load attribute values from Plex XML response. """ 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 +1472,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, @@ -1555,7 +1569,7 @@ class ResourceConnection(PlexObject): TAG = 'connection' def _loadData(self, data): - self._data = 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')) @@ -1598,7 +1612,7 @@ class MyPlexDevice(PlexObject): key = 'https://plex.tv/devices.xml' def _loadData(self, data): - self._data = 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') @@ -1617,7 +1631,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` @@ -1939,6 +1956,7 @@ class AccountOptOut(PlexObject): CHOICES = {'opt_in', 'opt_out', 'opt_out_managed'} def _loadData(self, data): + """ Load attribute values from Plex XML response. """ self.key = data.attrib.get('key') self.value = data.attrib.get('value') @@ -1997,6 +2015,7 @@ def __repr__(self): return f'<{self.__class__.__name__}:{self.ratingKey}>' def _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') @@ -2026,7 +2045,7 @@ class GeoLocation(PlexObject): TAG = 'location' def _loadData(self, data): - self._data = 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 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..8875ef076 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 + """ 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")) @@ -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 @@ -254,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): @@ -283,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): @@ -301,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 diff --git a/plexapi/server.py b/plexapi/server.py index 8cd110d80..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. """ - self._data = 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): - self._data = 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): - self._data = 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,6 +1128,7 @@ class Release(PlexObject): key = '/updater/status' def _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') @@ -1154,7 +1154,7 @@ class SystemAccount(PlexObject): TAG = 'Account' def _loadData(self, data): - self._data = 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') @@ -1183,7 +1183,7 @@ class SystemDevice(PlexObject): TAG = 'Device' def _loadData(self, data): - self._data = 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')) @@ -1209,7 +1209,7 @@ class StatisticsBandwidth(PlexObject): TAG = 'StatisticsBandwidth' def _loadData(self, data): - self._data = 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')) @@ -1251,7 +1251,7 @@ class StatisticsResources(PlexObject): TAG = 'StatisticsResources' def _loadData(self, data): - self._data = 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')) @@ -1279,7 +1279,7 @@ class ButlerTask(PlexObject): TAG = 'ButlerTask' def _loadData(self, data): - self._data = 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')) @@ -1301,7 +1301,7 @@ def __repr__(self): return f"<{self.__class__.__name__}:{self.machineIdentifier}>" def _loadData(self, data): - self._data = 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 c191e3689..4e016e32f 100644 --- a/plexapi/settings.py +++ b/plexapi/settings.py @@ -34,11 +34,10 @@ def __setattr__(self, attr, value): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = 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) diff --git a/plexapi/sonos.py b/plexapi/sonos.py index 8f1295f44..1b61fd568 100644 --- a/plexapi/sonos.py +++ b/plexapi/sonos.py @@ -47,7 +47,6 @@ class PlexSonosClient(PlexClient): """ def __init__(self, account, data, timeout=None): - self._data = 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..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): - self._data = 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): - self._data = 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 9e4201b88..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, 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,10 @@ class Video(PlexPartialObject, PlayedUnplayedMixin): def _loadData(self, data): """ Load attribute values from Plex XML response. """ - self._data = 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 +70,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 +399,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 +623,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 +836,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 +849,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_data_property + def collections(self): + return self.findItems(self._data, media.Collection) + + @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 ratings(self): + return self.findItems(self._data, media.Rating) + + @cached_data_property + def ultraBlurColors(self): + return self.findItem(self._data, media.UltraBlurColors) + def __iter__(self): for episode in self.episodes(): yield episode @@ -942,11 +1034,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 +1045,16 @@ 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 +1064,54 @@ 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_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. """ @@ -1149,12 +1277,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 +1289,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 diff --git a/tests/test_library.py b/tests/test_library.py index 261f63716..867302614 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -959,3 +959,16 @@ 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 + 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 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 e7f9c8dbe..e7c2ad1c5 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) - assert movie.__dict__.get('media') == [] - assert movie.__dict__.get('summary') is None + # 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): @@ -1536,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 str(before_guids) == str(after_guids), "GUIDs should not have changed content after a reload"