From 78dfd6c8a9cd7afe4c41801330daccb5d4c7f342 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:23:22 -0700 Subject: [PATCH 1/4] Remove manual cache attributes --- plexapi/collection.py | 27 +++++++------ plexapi/library.py | 83 +++++++++++++++++++-------------------- plexapi/playlist.py | 90 ++++++++++++++++++++++--------------------- plexapi/server.py | 32 ++++++++------- 4 files changed, 124 insertions(+), 108 deletions(-) diff --git a/plexapi/collection.py b/plexapi/collection.py index 17e4524bb..308604a0b 100644 --- a/plexapi/collection.py +++ b/plexapi/collection.py @@ -104,9 +104,6 @@ def _loadData(self, data): self.type = data.attrib.get('type') 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): @@ -174,20 +171,26 @@ def isPhoto(self): def children(self): return self.items() + @cached_data_property + def _filters(self): + """ Cache for filters. """ + return self._parseFilters(self.content) + def filters(self): """ Returns the search filter dict for smart collection. The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search` to get the list of items. """ - if self.smart and self._filters is None: - self._filters = self._parseFilters(self.content) return self._filters + @cached_data_property + def _section(self): + """ Cache for section. """ + return super(Collection, self).section() + def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to. """ - if self._section is None: - self._section = super(Collection, self).section() return self._section def item(self, title): @@ -204,12 +207,14 @@ def item(self, title): return item raise NotFound(f'Item with title "{title}" not found in the collection') + @cached_data_property + def _items(self): + """ Cache for the items. """ + key = f'{self.key}/children' + return self.fetchItems(key) + def items(self): """ Returns a list of all items in the collection. """ - if self._items is None: - key = f'{self.key}/children' - items = self.fetchItems(key) - self._items = items return self._items def visibility(self): diff --git a/plexapi/library.py b/plexapi/library.py index 271ad74f2..8805214ef 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -6,7 +6,6 @@ import warnings from collections import defaultdict from datetime import datetime -from functools import cached_property from urllib.parse import parse_qs, quote_plus, urlencode, urlparse from plexapi import log, media, utils @@ -44,9 +43,8 @@ def _loadData(self, data): self.mediaTagVersion = data.attrib.get('mediaTagVersion') self.title1 = data.attrib.get('title1') self.title2 = data.attrib.get('title2') - self._sectionsByID = {} # cached sections by key - self._sectionsByTitle = {} # cached sections by title + @cached_data_property def _loadSections(self): """ Loads and caches all the library sections. """ key = '/library/sections' @@ -64,15 +62,23 @@ def _loadSections(self): sectionsByID[section.key] = section sectionsByTitle[section.title.lower().strip()].append(section) - self._sectionsByID = sectionsByID - self._sectionsByTitle = dict(sectionsByTitle) + return sectionsByID, dict(sectionsByTitle) + + @property + def _sectionsByID(self): + """ Returns a dictionary of all library sections by ID. """ + return self._loadSections[0] + + @property + def _sectionsByTitle(self): + """ Returns a dictionary of all library sections by title. """ + return self._loadSections[1] def sections(self): """ Returns a list of all media sections in this library. Library sections may be any of :class:`~plexapi.library.MovieSection`, :class:`~plexapi.library.ShowSection`, :class:`~plexapi.library.MusicSection`, :class:`~plexapi.library.PhotoSection`. """ - self._loadSections() return list(self._sectionsByID.values()) def section(self, title): @@ -87,8 +93,6 @@ def section(self, title): :exc:`~plexapi.exceptions.NotFound`: The library section title is not found on the server. """ normalized_title = title.lower().strip() - if not self._sectionsByTitle or normalized_title not in self._sectionsByTitle: - self._loadSections() try: sections = self._sectionsByTitle[normalized_title] except KeyError: @@ -110,8 +114,6 @@ def sectionByID(self, sectionID): Raises: :exc:`~plexapi.exceptions.NotFound`: The library section ID is not found on the server. """ - if not self._sectionsByID or sectionID not in self._sectionsByID: - self._loadSections() try: return self._sectionsByID[sectionID] except KeyError: @@ -448,18 +450,12 @@ def _loadData(self, data): self.type = data.attrib.get('type') self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.uuid = data.attrib.get('uuid') - # Private attrs as we don't want a reload. - self._filterTypes = None - self._fieldTypes = None - self._totalViewSize = None - self._totalDuration = None - self._totalStorage = None @cached_data_property def locations(self): return self.listAttrs(self._data, 'path', etag='Location') - @cached_property + @cached_data_property def totalSize(self): """ Returns the total number of items in the library for the default library type. """ return self.totalViewSize(includeCollections=False) @@ -467,16 +463,12 @@ def totalSize(self): @property def totalDuration(self): """ Returns the total duration (in milliseconds) of items in the library. """ - if self._totalDuration is None: - self._getTotalDurationStorage() - return self._totalDuration + return self._getTotalDurationStorage[0] @property def totalStorage(self): """ Returns the total storage (in bytes) of items in the library. """ - if self._totalStorage is None: - self._getTotalDurationStorage() - return self._totalStorage + return self._getTotalDurationStorage[1] def __getattribute__(self, attr): # Intercept to call EditFieldMixin and EditTagMixin methods @@ -492,6 +484,7 @@ def __getattribute__(self, attr): ) return value + @cached_data_property def _getTotalDurationStorage(self): """ Queries the Plex server for the total library duration and storage and caches the values. """ data = self._server.query('/media/providers?includeStorage=1') @@ -502,8 +495,10 @@ def _getTotalDurationStorage(self): ) directory = next(iter(data.findall(xpath)), None) if directory: - self._totalDuration = utils.cast(int, directory.attrib.get('durationTotal')) - self._totalStorage = utils.cast(int, directory.attrib.get('storageTotal')) + totalDuration = utils.cast(int, directory.attrib.get('durationTotal')) + totalStorage = utils.cast(int, directory.attrib.get('storageTotal')) + return totalDuration, totalStorage + return None, None def totalViewSize(self, libtype=None, includeCollections=True): """ Returns the total number of items in the library for a specified libtype. @@ -875,6 +870,7 @@ def deleteMediaPreviews(self): self._server.query(key, method=self._server._session.delete) return self + @cached_data_property def _loadFilters(self): """ Retrieves and caches the list of :class:`~plexapi.library.FilteringType` and list of :class:`~plexapi.library.FilteringFieldType` for this library section. @@ -884,23 +880,23 @@ def _loadFilters(self): key = _key.format(key=self.key, filter='all') data = self._server.query(key) - self._filterTypes = self.findItems(data, FilteringType, rtag='Meta') - self._fieldTypes = self.findItems(data, FilteringFieldType, rtag='Meta') + filterTypes = self.findItems(data, FilteringType, rtag='Meta') + fieldTypes = self.findItems(data, FilteringFieldType, rtag='Meta') if self.TYPE != 'photo': # No collections for photo library key = _key.format(key=self.key, filter='collections') data = self._server.query(key) - self._filterTypes.extend(self.findItems(data, FilteringType, rtag='Meta')) + filterTypes.extend(self.findItems(data, FilteringType, rtag='Meta')) # Manually add guid field type, only allowing "is" operator guidFieldType = '' - self._fieldTypes.append(self._manuallyLoadXML(guidFieldType, FilteringFieldType)) + fieldTypes.append(self._manuallyLoadXML(guidFieldType, FilteringFieldType)) + + return filterTypes, fieldTypes def filterTypes(self): """ Returns a list of available :class:`~plexapi.library.FilteringType` for this library section. """ - if self._filterTypes is None: - self._loadFilters() - return self._filterTypes + return self._loadFilters[0] def getFilterType(self, libtype=None): """ Returns a :class:`~plexapi.library.FilteringType` for a specified libtype. @@ -922,9 +918,7 @@ def getFilterType(self, libtype=None): def fieldTypes(self): """ Returns a list of available :class:`~plexapi.library.FilteringFieldType` for this library section. """ - if self._fieldTypes is None: - self._loadFilters() - return self._fieldTypes + return self._loadFilters[1] def getFieldType(self, fieldType): """ Returns a :class:`~plexapi.library.FilteringFieldType` for a specified fieldType. @@ -2233,10 +2227,13 @@ def _loadData(self, data): self.style = data.attrib.get('style') self.title = data.attrib.get('title') self.type = data.attrib.get('type') - self._section = None # cache for self.section + + def __len__(self): + return self.size @cached_data_property - def items(self): + def _items(self): + """ Cache for items. """ if self.more and self.key: # If there are more items to load, fetch them items = self.fetchItems(self.key) self.more = False @@ -2245,8 +2242,9 @@ def items(self): # Otherwise, all the data is in the initial _data XML response return self.findItems(self._data) - def __len__(self): - return self.size + def items(self): + """ Returns a list of all items in the hub. """ + return self._items def reload(self): """ Delete cached data to allow reloading of hub items. """ @@ -2255,11 +2253,14 @@ def reload(self): self.more = utils.cast(bool, self._data.attrib.get('more')) self.size = utils.cast(int, self._data.attrib.get('size')) + @cached_data_property + def _section(self): + """ Cache for section. """ + return self._server.library.sectionByID(self.librarySectionID) + def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this hub belongs to. """ - if self._section is None: - self._section = self._server.library.sectionByID(self.librarySectionID) return self._section diff --git a/plexapi/playlist.py b/plexapi/playlist.py index 0662e6165..0fc79bf50 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -76,9 +76,6 @@ def _loadData(self, data): self.titleSort = data.attrib.get('titleSort', self.title) self.type = data.attrib.get('type') self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) - 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): @@ -136,15 +133,36 @@ def _getPlaylistItemID(self, item): return _item.playlistItemID raise NotFound(f'Item with title "{item.title}" not found in the playlist') + @cached_data_property + def _filters(self): + """ Cache for filters. """ + return self._parseFilters(self.content) + def filters(self): """ Returns the search filter dict for smart playlist. The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search` to get the list of items. """ - if self.smart and self._filters is None: - self._filters = self._parseFilters(self.content) return self._filters + @cached_data_property + def _section(self): + """ Cache for section. """ + if not self.smart: + raise BadRequest('Regular playlists are not associated with a library.') + + # Try to parse the library section from the content URI string + match = re.search(r'/library/sections/(\d+)/all', unquote(self.content or '')) + if match: + sectionKey = int(match.group(1)) + return self._server.library.sectionByID(sectionKey) + + # Try to get the library section from the first item in the playlist + if self.items(): + return self.items()[0].section() + + raise Unsupported('Unable to determine the library section') + def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to. @@ -152,24 +170,6 @@ def section(self): :class:`plexapi.exceptions.BadRequest`: When trying to get the section for a regular playlist. :class:`plexapi.exceptions.Unsupported`: When unable to determine the library section. """ - if not self.smart: - raise BadRequest('Regular playlists are not associated with a library.') - - if self._section is None: - # Try to parse the library section from the content URI string - match = re.search(r'/library/sections/(\d+)/all', unquote(self.content or '')) - if match: - sectionKey = int(match.group(1)) - self._section = self._server.library.sectionByID(sectionKey) - return self._section - - # Try to get the library section from the first item in the playlist - if self.items(): - self._section = self.items()[0].section() - return self._section - - raise Unsupported('Unable to determine the library section') - return self._section def item(self, title): @@ -186,28 +186,32 @@ def item(self, title): return item raise NotFound(f'Item with title "{title}" not found in the playlist') - def items(self): - """ Returns a list of all items in the playlist. """ + @cached_data_property + def _items(self): + """ Cache for items. """ if self.radio: return [] - if self._items is None: - key = f'{self.key}/items' - items = self.fetchItems(key) - - # Cache server connections to avoid reconnecting for each item - _servers = {} - for item in items: - if item.sourceURI: - serverID = item.sourceURI.split('/')[2] - if serverID not in _servers: - try: - _servers[serverID] = self._server.myPlexAccount().resource(serverID).connect() - except NotFound: - # Override the server connection with None if the server is not found - _servers[serverID] = None - item._server = _servers[serverID] - - self._items = items + + key = f'{self.key}/items' + items = self.fetchItems(key) + + # Cache server connections to avoid reconnecting for each item + _servers = {} + for item in items: + if item.sourceURI: + serverID = item.sourceURI.split('/')[2] + if serverID not in _servers: + try: + _servers[serverID] = self._server.myPlexAccount().resource(serverID).connect() + except NotFound: + # Override the server connection with None if the server is not found + _servers[serverID] = None + item._server = _servers[serverID] + + return items + + def items(self): + """ Returns a list of all items in the playlist. """ return self._items def get(self, title): diff --git a/plexapi/server.py b/plexapi/server.py index 8e280e0ea..b741bec2d 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -8,7 +8,7 @@ from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter from plexapi import utils from plexapi.alert import AlertListener -from plexapi.base import PlexObject +from plexapi.base import PlexObject, cached_data_property from plexapi.client import PlexClient from plexapi.collection import Collection from plexapi.exceptions import BadRequest, NotFound, Unauthorized @@ -109,9 +109,6 @@ def __init__(self, baseurl=None, token=None, session=None, timeout=None): self._showSecrets = CONFIG.get('log.show_secrets', '').lower() == 'true' self._session = session or requests.Session() self._timeout = timeout or TIMEOUT - self._myPlexAccount = None # cached myPlexAccount - self._systemAccounts = None # cached list of SystemAccount - self._systemDevices = None # cached list of SystemDevice data = self.query(self.key, timeout=self._timeout) super(PlexServer, self).__init__(self, data, self.key) @@ -274,11 +271,14 @@ def switchUser(self, user, session=None, timeout=None): timeout = self._timeout return PlexServer(self._baseurl, token=userToken, session=session, timeout=timeout) + @cached_data_property + def _systemAccounts(self): + """ Cache for systemAccounts. """ + key = '/accounts' + return self.fetchItems(key, SystemAccount) + def systemAccounts(self): """ Returns a list of :class:`~plexapi.server.SystemAccount` objects this server contains. """ - if self._systemAccounts is None: - key = '/accounts' - self._systemAccounts = self.fetchItems(key, SystemAccount) return self._systemAccounts def systemAccount(self, accountID): @@ -292,11 +292,14 @@ def systemAccount(self, accountID): except StopIteration: raise NotFound(f'Unknown account with accountID={accountID}') from None + @cached_data_property + def _systemDevices(self): + """ Cache for systemDevices. """ + key = '/devices' + return self.fetchItems(key, SystemDevice) + def systemDevices(self): """ Returns a list of :class:`~plexapi.server.SystemDevice` objects this server contains. """ - if self._systemDevices is None: - key = '/devices' - self._systemDevices = self.fetchItems(key, SystemDevice) return self._systemDevices def systemDevice(self, deviceID): @@ -310,14 +313,17 @@ def systemDevice(self, deviceID): except StopIteration: raise NotFound(f'Unknown device with deviceID={deviceID}') from None + @cached_data_property + def _myPlexAccount(self): + """ Cache for myPlexAccount. """ + from plexapi.myplex import MyPlexAccount + return MyPlexAccount(token=self._token, session=self._session) + def myPlexAccount(self): """ Returns a :class:`~plexapi.myplex.MyPlexAccount` object using the same token to access this server. If you are not the owner of this PlexServer you're likely to receive an authentication error calling this. """ - if self._myPlexAccount is None: - from plexapi.myplex import MyPlexAccount - self._myPlexAccount = MyPlexAccount(token=self._token, session=self._session) return self._myPlexAccount def _myPlexClientPorts(self): From 176a3ff8b06eef29aac63ad2486a47521987e1ca Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:23:43 -0700 Subject: [PATCH 2/4] Replace cached_property with cached_data_property --- plexapi/base.py | 2 +- plexapi/server.py | 5 ++--- plexapi/video.py | 11 +++++------ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/plexapi/base.py b/plexapi/base.py index df92fb2d7..3707427e0 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -1038,7 +1038,7 @@ def sessions(self): def transcodeSessions(self): return [self.transcodeSession] if self.transcodeSession else [] - @cached_property + @cached_data_property def user(self): """ Returns the :class:`~plexapi.myplex.MyPlexAccount` object (for admin) or :class:`~plexapi.myplex.MyPlexUser` object (for users) for this session. diff --git a/plexapi/server.py b/plexapi/server.py index b741bec2d..7cdd804d8 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- import os -from functools import cached_property from urllib.parse import urlencode import requests @@ -167,7 +166,7 @@ def _headers(self, **kwargs): def _uriRoot(self): return f'server://{self.machineIdentifier}/com.plexapp.plugins.library' - @cached_property + @cached_data_property def library(self): """ Library to browse or search your media. """ try: @@ -178,7 +177,7 @@ def library(self): data = self.query('/library/sections/') return Library(self, data) - @cached_property + @cached_data_property def settings(self): """ Returns a list of all server settings. """ data = self.query(Settings.key) diff --git a/plexapi/video.py b/plexapi/video.py index 1ca73e93c..597bbca7f 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- import os -from functools import cached_property from pathlib import Path from urllib.parse import quote_plus @@ -1112,7 +1111,7 @@ def writers(self): def ultraBlurColors(self): return self.findItem(self._data, media.UltraBlurColors) - @cached_property + @cached_data_property def parentKey(self): """ Returns the parentKey. Refer to the Episode attributes. """ if self._parentKey: @@ -1121,7 +1120,7 @@ def parentKey(self): return f'/library/metadata/{self.parentRatingKey}' return None - @cached_property + @cached_data_property def parentRatingKey(self): """ Returns the parentRatingKey. Refer to the Episode attributes. """ if self._parentRatingKey is not None: @@ -1134,7 +1133,7 @@ def parentRatingKey(self): return self._season.ratingKey return None - @cached_property + @cached_data_property def parentThumb(self): """ Returns the parentThumb. Refer to the Episode attributes. """ if self._parentThumb: @@ -1143,7 +1142,7 @@ def parentThumb(self): return self._season.thumb return None - @cached_property + @cached_data_property def _season(self): """ Returns the :class:`~plexapi.video.Season` object by querying for the show's children. """ if self.grandparentKey and self.parentIndex is not None: @@ -1183,7 +1182,7 @@ def episodeNumber(self): """ Returns the episode number. """ return self.index - @cached_property + @cached_data_property def seasonNumber(self): """ Returns the episode's season number. """ if isinstance(self.parentIndex, int): From e0ed2d1e1948176e1a63ff3b9ad1cda2b60c33f4 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 9 Apr 2025 10:49:17 -0700 Subject: [PATCH 3/4] Fix calls to hub.items --- plexapi/library.py | 9 +++++++-- plexapi/server.py | 4 ++-- tests/test_video.py | 4 ++-- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/plexapi/library.py b/plexapi/library.py index 8805214ef..6677abde5 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -1967,7 +1967,7 @@ def albums(self): def stations(self): """ Returns a list of :class:`~plexapi.playlist.Playlist` stations in this section. """ - return next((hub.items for hub in self.hubs() if hub.context == 'hub.music.stations'), None) + return next((hub._partialItems for hub in self.hubs() if hub.context == 'hub.music.stations'), None) def searchArtists(self, **kwargs): """ Search for an artist. See :func:`~plexapi.library.LibrarySection.search` for usage. """ @@ -2231,6 +2231,11 @@ def _loadData(self, data): def __len__(self): return self.size + @cached_data_property + def _partialItems(self): + """ Cache for partial items. """ + return self.findItems(self._data) + @cached_data_property def _items(self): """ Cache for items. """ @@ -2240,7 +2245,7 @@ def _items(self): self.size = len(items) return items # Otherwise, all the data is in the initial _data XML response - return self.findItems(self._data) + return self._partialItems def items(self): """ Returns a list of all items in the hub. """ diff --git a/plexapi/server.py b/plexapi/server.py index 7cdd804d8..1c6427d42 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -806,9 +806,9 @@ def search(self, query, mediatype=None, limit=None, sectionId=None): for hub in self.fetchItems(key, Hub): if mediatype: if hub.type == mediatype: - return hub.items + return hub._partialItems else: - results += hub.items + results += hub._partialItems return results def continueWatching(self): diff --git a/tests/test_video.py b/tests/test_video.py index 0f15120ba..2b10ff0b6 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -570,7 +570,7 @@ def test_video_Movie_hubs(movies): assert hub.context == "hub.movie.similar" assert utils.is_metadata(hub.hubKey) assert hub.hubIdentifier == "movie.similar" - assert len(hub.items) == hub.size + assert len(hub._partialItems) == hub.size assert utils.is_metadata(hub.key) assert hub.more is False assert hub.random is False @@ -582,7 +582,7 @@ def test_video_Movie_hubs(movies): # Force hub reload hub.more = True hub.reload() - assert len(hub.items) == hub.size + assert len(hub.items()) == hub.size assert hub.more is False assert hub.size == 1 From fd092808ce686835864d1616e672ba21815127df Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sat, 12 Apr 2025 18:00:27 -0700 Subject: [PATCH 4/4] Invalidate cached libraries after adding/removing a section --- plexapi/library.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plexapi/library.py b/plexapi/library.py index b71dfa61a..5d54b6a8b 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -387,7 +387,9 @@ def add(self, name='', type='', agent='', scanner='', location='', language='en- if kwargs: prefs_params = {f'prefs[{k}]': v for k, v in kwargs.items()} part += f'&{urlencode(prefs_params)}' - return self._server.query(part, method=self._server._session.post) + data = self._server.query(part, method=self._server._session.post) + self._invalidateCachedProperties() + return data def history(self, maxresults=None, mindate=None): """ Get Play History for all library Sections for the owner. @@ -529,7 +531,9 @@ def totalViewSize(self, libtype=None, includeCollections=True): def delete(self): """ Delete a library section. """ try: - return self._server.query(f'/library/sections/{self.key}', method=self._server._session.delete) + data = self._server.query(f'/library/sections/{self.key}', method=self._server._session.delete) + self._server.library._invalidateCachedProperties() + return data except BadRequest: # pragma: no cover msg = f'Failed to delete library {self.key}' msg += 'You may need to allow this permission in your Plex settings.'