Skip to content

Lazy loading and caching for attributes set in _loadData(..) #1510

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
0806788
feat: Implemented a caching mecahnism for PlexObject classes
eliasbenb Apr 4, 2025
9fe844b
perf: Cache all data attributes that are computation heavy
eliasbenb Apr 4, 2025
d8860c9
fix: Don't invalidate property cache on object initialization
eliasbenb Apr 4, 2025
c31f7c6
refactor: For all Plex objects, call the base class's loadData functi…
eliasbenb Apr 4, 2025
9d5abc9
perf: Convert attributes that call `findItem` to cached data properties
eliasbenb Apr 4, 2025
e8348df
perf: Attempt to parse XML strings without cleaning (which is expensi…
eliasbenb Apr 4, 2025
aed1fd9
Revert "perf: Attempt to parse XML strings without cleaning (which is…
eliasbenb Apr 4, 2025
da10d35
fix: Use the correct attribute name when deleting invalidated cached …
eliasbenb Apr 4, 2025
f976cf0
fix: Follow the same behavior as before the introduction of cached pr…
eliasbenb Apr 4, 2025
526e47b
fix: Typo in declaring cached data property attributes
eliasbenb Apr 4, 2025
c3cd373
test: Don't use ` __dict__` to access attributes
eliasbenb Apr 4, 2025
83e6286
test: Don't reload objects for the test_video_Movie_reload_kwargs test
eliasbenb Apr 5, 2025
0ef26ed
fix: Ensure `PlexObject._loadData` is called in child classes that ov…
eliasbenb Apr 5, 2025
4f66a0a
fix: Handle special cache invalidation for LibrarySection objects
eliasbenb Apr 5, 2025
947393d
test: Tests for cache invalidation in library and video objects
eliasbenb Apr 5, 2025
2212c2d
test: Removed unexpected exception from test_library_section_cache_in…
eliasbenb Apr 5, 2025
aea4282
test: Replaced incorrect object ID comparisons with string string rep…
eliasbenb Apr 5, 2025
8fa7d13
refactor: Removed uneeded variable assignment
eliasbenb Apr 7, 2025
cd2c8f7
perf: Convert languageCodes and mediaTypes to lazy-loaded cached prop…
eliasbenb Apr 7, 2025
6ad68ce
refactor: Delegate cache invalidation logic to the reload function in…
eliasbenb Apr 7, 2025
90bf0fd
style: Removed unecessary explicit object inheritence (implicit since…
eliasbenb Apr 7, 2025
8c91769
perf: Lazy load expensive attributes in PlexSession
eliasbenb Apr 7, 2025
ca0b1c8
docs: Make it clearer that Playable, PlexSession, and PlexHistory are…
eliasbenb Apr 7, 2025
88e165f
fix: Handle special reload cache invalidation logic for MyPlexAccount…
eliasbenb Apr 7, 2025
0229729
refactor: Unused import
eliasbenb Apr 7, 2025
28bd9db
style: Reorder functions for more accurate order of operation
eliasbenb Apr 7, 2025
81c16b7
docs: Removed typo
eliasbenb Apr 7, 2025
53364a9
refactor: Fixed all flake8 unused import warning
eliasbenb Apr 8, 2025
65303c2
fix: Invalidate the cache after all PUT queries in the PlayQueue object
eliasbenb Apr 8, 2025
0320360
refactor: Call loadData with invalidation in the Settings class for f…
eliasbenb Apr 9, 2025
a06a43f
refactor: Better align the items and reload functions with the intern…
eliasbenb Apr 9, 2025
c98a9d1
fix: Reset the state of Hub pagination metadata on reloads
eliasbenb Apr 9, 2025
e3fc9c8
docs: Updated docstring for `Hub.reload(...)` changes
eliasbenb Apr 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 106 additions & 28 deletions plexapi/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, PlexObject, PlexPartialObject, PlexHistory, PlexSession, cached_data_property
from plexapi.exceptions import BadRequest
from plexapi.mixins import (
AdvancedSettingsMixin, SplitMergeMixin, UnmatchMatchMixin, ExtrasMixin, HubsMixin, PlayedUnplayedMixin, RatingMixin,
Expand Down Expand Up @@ -59,14 +59,12 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin):

def _loadData(self, data):
""" Load attribute values from Plex XML response. """
self._data = 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')
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'))
Expand All @@ -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')
Expand All @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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')
Expand All @@ -372,12 +403,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
Expand Down Expand Up @@ -495,21 +555,15 @@ 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')
self.grandparentRatingKey = utils.cast(int, data.attrib.get('grandparentRatingKey'))
self.grandparentTheme = data.attrib.get('grandparentTheme')
self.grandparentThumb = data.attrib.get('grandparentThumb')
self.grandparentTitle = data.attrib.get('grandparentTitle')
self.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'))
Expand All @@ -525,6 +579,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
Expand Down
56 changes: 53 additions & 3 deletions plexapi/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 = 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):
Expand Down Expand Up @@ -1124,7 +1174,7 @@ def extend(
setattr(self, key, getattr(__iterable, key))

def _loadData(self, data):
self._data = 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')
Expand Down
4 changes: 2 additions & 2 deletions plexapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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'))
Expand Down
24 changes: 18 additions & 6 deletions plexapi/collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from urllib.parse import quote_plus

from plexapi import media, utils
from plexapi.base import PlexPartialObject
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 (
Expand Down Expand Up @@ -69,7 +69,7 @@ class Collection(
TYPE = 'collection'

def _loadData(self, data):
self._data = 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')
Expand All @@ -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')
Expand All @@ -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())

Expand Down
Loading
Loading