Update plexapi==4.17.0

This commit is contained in:
JonnyWong16 2025-05-10 16:13:23 -07:00
commit f6bffe1850
No known key found for this signature in database
GPG key ID: B1F1F9807184697A
32 changed files with 1224 additions and 966 deletions

View file

@ -8,7 +8,7 @@ from urllib.parse import quote_plus
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 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin):
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 @@ class Audio(PlexPartialObject, PlayedUnplayedMixin):
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 @@ class Artist(
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 @@ class Album(
""" 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 @@ class Album(
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 @@ class Track(
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 @@ class Track(
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 @@ class Track(
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

View file

@ -39,7 +39,42 @@ OPERATORS = {
}
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:
@ -387,7 +422,7 @@ class PlexObject:
return results
def reload(self, key=None, **kwargs):
""" Reload the data for this object from self.key.
""" Reload the data for this object.
Parameters:
key (string, optional): Override the key to reload.
@ -435,7 +470,7 @@ class PlexObject:
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,9 +532,35 @@ class PlexObject:
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.')
def _findAndLoadElem(self, data, **kwargs):
""" Find and load the first element in the data that matches the specified attributes. """
for elem in data:
if self._checkAttrs(elem, **kwargs):
self._invalidateCacheAndLoadData(elem)
@property
def _searchType(self):
return self.TYPE
@ -754,7 +815,7 @@ class PlexPartialObject(PlexObject):
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 +825,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 +993,8 @@ class Playable:
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,23 +1007,44 @@ 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_property
@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_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.
@ -978,18 +1061,11 @@ class PlexSession(object):
"""
return self._reload()
def _reload(self, _autoReload=False, **kwargs):
""" Perform the actual reload. """
# Do not auto reload sessions
if _autoReload:
return self
def _reload(self, **kwargs):
""" Reload the data for the session. """
key = self._initpath
data = self._server.query(key)
for elem in data:
if elem.attrib.get('sessionKey') == str(self.sessionKey):
self._loadData(elem)
break
self._findAndLoadElem(data, sessionKey=str(self.sessionKey))
return self
def source(self):
@ -1010,8 +1086,8 @@ class PlexSession(object):
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 +1097,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 +1201,7 @@ class MediaContainer(
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')

View file

@ -115,7 +115,7 @@ class PlexClient(PlexObject):
)
else:
client = data[0]
self._loadData(client)
self._invalidateCacheAndLoadData(client)
return self
def reload(self):
@ -124,7 +124,6 @@ class PlexClient(PlexObject):
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')
@ -197,8 +196,7 @@ class PlexClient(PlexObject):
raise NotFound(message)
else:
raise BadRequest(message)
data = utils.cleanXMLString(response.text).encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
return utils.parseXMLString(response.text)
def sendCommand(self, command, proxy=None, **params):
""" Convenience wrapper around :func:`~plexapi.client.PlexClient.query` to more easily
@ -222,7 +220,7 @@ class PlexClient(PlexObject):
proxy = self._proxyThroughServer if proxy is None else proxy
query = self._server.query if proxy else self.query
# Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244
# Workaround for ptp. See https://github.com/pushingkarmaorg/python-plexapi/issues/244
t = time.time()
if command == 'timeline/poll':
self._last_call = t
@ -606,7 +604,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'))

View file

@ -3,7 +3,7 @@ from pathlib import Path
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 @@ class Collection(
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,12 +102,24 @@ class Collection(
self.title = data.attrib.get('title')
self.titleSort = data.attrib.get('titleSort', self.title)
self.type = data.attrib.get('type')
self.ultraBlurColors = self.findItem(data, media.UltraBlurColors)
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self.userRating = utils.cast(float, data.attrib.get('userRating'))
self._items = None # cache for self.items
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())
@ -162,20 +171,26 @@ class Collection(
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):
@ -192,12 +207,14 @@ class Collection(
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):

View file

@ -3,7 +3,7 @@
# Library version
MAJOR_VERSION = 4
MINOR_VERSION = 16
PATCH_VERSION = 1
MINOR_VERSION = 17
PATCH_VERSION = 0
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}"

View file

@ -6,11 +6,10 @@ from typing import Any, TYPE_CHECKING
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
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,14 +38,13 @@ 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')
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 @@ class Library(PlexObject):
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 @@ class Library(PlexObject):
: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 @@ class Library(PlexObject):
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:
@ -385,7 +387,9 @@ class Library(PlexObject):
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.
@ -432,7 +436,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 +445,6 @@ class LibrarySection(PlexObject):
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')
@ -449,14 +452,12 @@ class LibrarySection(PlexObject):
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_property
@cached_data_property
def locations(self):
return self.listAttrs(self._data, 'path', etag='Location')
@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)
@ -464,16 +465,12 @@ class LibrarySection(PlexObject):
@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
@ -489,6 +486,7 @@ class LibrarySection(PlexObject):
)
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')
@ -499,8 +497,10 @@ class LibrarySection(PlexObject):
)
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.
@ -531,18 +531,20 @@ class LibrarySection(PlexObject):
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.'
log.error(msg)
raise
def reload(self):
def _reload(self, **kwargs):
""" Reload the data for the library section. """
self._server.library._loadSections()
newLibrary = self._server.library.sectionByID(self.key)
self.__dict__.update(newLibrary.__dict__)
key = self._initpath
data = self._server.query(key)
self._findAndLoadElem(data, key=str(self.key))
return self
def edit(self, agent=None, **kwargs):
@ -871,6 +873,7 @@ class LibrarySection(PlexObject):
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.
@ -880,23 +883,23 @@ class LibrarySection(PlexObject):
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 = '<FieldType type="guid"><Operator key="=" title="is"/></FieldType>'
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.
@ -918,9 +921,7 @@ class LibrarySection(PlexObject):
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.
@ -1969,7 +1970,7 @@ class MusicSection(LibrarySection, ArtistEditMixins, AlbumEditMixins, TrackEditM
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. """
@ -2165,7 +2166,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 +2194,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')
@ -2208,9 +2207,10 @@ class Hub(PlexObject):
context (str): The context of the hub.
hubKey (str): API URL for these specific hub items.
hubIdentifier (str): The identifier of the hub.
items (list): List of items in the hub.
items (list): List of items in the hub (automatically loads all items if more is True).
key (str): API URL for the hub.
more (bool): True if there are more items to load (call reload() to fetch all items).
random (bool): True if the items in the hub are randomized.
more (bool): True if there are more items to load (call items to fetch all items).
size (int): The number of items in the hub.
style (str): The style of the hub.
title (str): The title of the hub.
@ -2220,36 +2220,57 @@ 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.random = utils.cast(bool, data.attrib.get('random', '0'))
self.size = utils.cast(int, data.attrib.get('size'))
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
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)
@cached_data_property
def _partialItems(self):
""" Cache for partial items. """
return self.findItems(self._data)
@cached_data_property
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
self.size = len(self.items)
self.size = len(items)
return items
# Otherwise, all the data is in the initial _data XML response
return self._partialItems
def items(self):
""" Returns a list of all items in the hub. """
return self._items
@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
def _reload(self, **kwargs):
""" Reload the data for the hub. """
key = self._initpath
data = self._server.query(key)
self._findAndLoadElem(data, hubIdentifier=self.hubIdentifier)
return self
class LibraryMediaTag(PlexObject):
""" Base class of library media tags.
@ -2279,7 +2300,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 +2688,25 @@ class FilteringType(PlexObject):
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 +2886,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 +2912,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 +2936,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 +2958,11 @@ class FilteringFieldType(PlexObject):
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 +2999,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 +3028,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')
@ -3020,11 +3041,11 @@ class ManagedHub(PlexObject):
parent = self._parent()
self.librarySectionID = parent.key if isinstance(parent, LibrarySection) else parent.librarySectionID
def reload(self):
def _reload(self, **kwargs):
""" Reload the data for this managed hub. """
key = f'/hubs/sections/{self.librarySectionID}/manage'
hub = self.fetchItem(key, self.__class__, identifier=self.identifier)
self.__dict__.update(hub.__dict__)
data = self._server.query(key)
self._findAndLoadElem(data, identifier=self.identifier)
return self
def move(self, after=None):
@ -3170,7 +3191,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 +3211,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 +3241,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 +3290,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__,

View file

@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
import xml
from pathlib import Path
from urllib.parse import quote_plus
from xml.etree import ElementTree
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 @@ class Media(PlexObject):
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 @@ class Media(PlexObject):
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')
@ -386,6 +386,7 @@ class AudioStream(MediaPartStream):
profile (str): The profile of the audio stream.
samplingRate (int): The sampling rate of the audio stream (ex: xxx)
streamIdentifier (int): The stream identifier of the audio stream.
visualImpaired (bool): True if this is a visually impaired (AD) audio stream.
Track_only_attributes: The following attributes are only available for tracks.
@ -413,6 +414,7 @@ class AudioStream(MediaPartStream):
self.profile = data.attrib.get('profile')
self.samplingRate = utils.cast(int, data.attrib.get('samplingRate'))
self.streamIdentifier = utils.cast(int, data.attrib.get('streamIdentifier'))
self.visualImpaired = utils.cast(bool, data.attrib.get('visualImpaired', '0'))
# Track only attributes
self.albumGain = utils.cast(float, data.attrib.get('albumGain'))
@ -523,6 +525,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 +572,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 +612,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 +631,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 +669,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 +745,6 @@ class MediaTag(PlexObject):
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 +955,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 +972,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 +993,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 +1015,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 +1040,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 +1060,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')
@ -1075,7 +1072,7 @@ class BaseResource(PlexObject):
data = f'{key}?url={quote_plus(self.ratingKey)}'
try:
self._server.query(data, method=self._server._session.put)
except xml.etree.ElementTree.ParseError:
except ElementTree.ParseError:
pass
@property
@ -1138,7 +1135,7 @@ class Chapter(PlexObject):
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 +1169,7 @@ class Marker(PlexObject):
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 +1203,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 +1223,7 @@ class SearchResult(PlexObject):
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 +1245,7 @@ class Agent(PlexObject):
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 +1253,17 @@ class Agent(PlexObject):
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 +1293,14 @@ class AgentMediaType(Agent):
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 +1331,7 @@ class Availability(PlexObject):
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')

View file

@ -4,13 +4,12 @@ import html
import threading
import time
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from xml.etree import ElementTree
import requests
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 +143,6 @@ class MyPlexAccount(PlexObject):
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 +183,6 @@ class MyPlexAccount(PlexObject):
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,21 +198,31 @@ class MyPlexAccount(PlexObject):
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``. """
return self.authToken
def _reload(self, key=None, **kwargs):
def _reload(self, **kwargs):
""" Perform the actual reload. """
data = self.query(self.key)
self._loadData(data)
self._invalidateCacheAndLoadData(data)
return self
def _headers(self, **kwargs):
@ -250,8 +257,7 @@ class MyPlexAccount(PlexObject):
return response.json()
elif 'text/plain' in response.headers.get('Content-Type', ''):
return response.text.strip()
data = utils.cleanXMLString(response.text).encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
return utils.parseXMLString(response.text)
def ping(self):
""" Ping the Plex.tv API.
@ -1206,7 +1212,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 +1230,13 @@ class MyPlexUser(PlexObject):
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 +1291,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 +1298,15 @@ class MyPlexInvite(PlexObject):
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 +1324,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 +1363,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 +1446,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 +1470,10 @@ class MyPlexResource(PlexObject):
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 +1567,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 +1610,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 +1629,10 @@ class MyPlexDevice(PlexObject):
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`
@ -1879,8 +1894,7 @@ class MyPlexPinLogin:
codename = codes.get(response.status_code)[0]
errtext = response.text.replace('\n', ' ')
raise BadRequest(f'({response.status_code}) {codename} {response.url}; {errtext}')
data = response.text.encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
return utils.parseXMLString(response.text)
def _connect(cls, url, token, session, timeout, results, i, job_is_done_event=None):
@ -1939,6 +1953,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 +2012,7 @@ class UserState(PlexObject):
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 +2042,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')

View file

@ -4,7 +4,7 @@ from pathlib import Path
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 @@ class Photoalbum(
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 @@ class Photoalbum(
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 @@ class Photo(
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 @@ class Photo(
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 @@ class Photo(
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 @@ class Photo(
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:

View file

@ -5,7 +5,7 @@ from pathlib import Path
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 @@ class Playlist(
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
@ -77,9 +76,10 @@ class Playlist(
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):
return self.findItems(self._data, media.Field)
def __len__(self): # pragma: no cover
return len(self.items())
@ -133,15 +133,36 @@ class Playlist(
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.
@ -149,24 +170,6 @@ class Playlist(
: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):
@ -183,28 +186,32 @@ class Playlist(
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]
key = f'{self.key}/items'
items = self.fetchItems(key)
self._items = items
# 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):

View file

@ -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 @@ class PlayQueue(PlexObject):
)
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 @@ class PlayQueue(PlexObject):
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 @@ class PlayQueue(PlexObject):
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 @@ class PlayQueue(PlexObject):
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

View file

@ -1,15 +1,13 @@
# -*- coding: utf-8 -*-
import os
from functools import cached_property
from urllib.parse import urlencode
from xml.etree import ElementTree
import requests
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
@ -110,15 +108,11 @@ class PlexServer(PlexObject):
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)
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'))
@ -172,7 +166,7 @@ class PlexServer(PlexObject):
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:
@ -183,7 +177,7 @@ class PlexServer(PlexObject):
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)
@ -276,11 +270,14 @@ class PlexServer(PlexObject):
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):
@ -294,11 +291,14 @@ class PlexServer(PlexObject):
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):
@ -312,21 +312,24 @@ class PlexServer(PlexObject):
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):
""" Sometimes the PlexServer does not properly advertise port numbers required
to connect. This attempts to look up device port number from plex.tv.
See issue #126: Make PlexServer.clients() more user friendly.
https://github.com/pkkid/python-plexapi/issues/126
https://github.com/pushingkarmaorg/python-plexapi/issues/126
"""
try:
ports = {}
@ -768,8 +771,7 @@ class PlexServer(PlexObject):
raise NotFound(message)
else:
raise BadRequest(message)
data = utils.cleanXMLString(response.text).encode('utf8')
return ElementTree.fromstring(data) if data.strip() else None
return utils.parseXMLString(response.text)
def search(self, query, mediatype=None, limit=None, sectionId=None):
""" Returns a list of media items or filter categories from the resulting
@ -804,9 +806,9 @@ class PlexServer(PlexObject):
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):
@ -1093,7 +1095,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 +1116,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 +1131,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 +1157,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 +1186,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 +1212,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 +1254,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 +1282,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 +1304,7 @@ class Identity(PlexObject):
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')

View file

@ -34,11 +34,10 @@ class Settings(PlexObject):
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)

View file

@ -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")

View file

@ -63,7 +63,7 @@ class SyncItem(PlexObject):
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 = []

View file

@ -17,12 +17,12 @@ from getpass import getpass
from hashlib import sha1
from threading import Event, Thread
from urllib.parse import quote
from xml.etree import ElementTree
import requests
from requests.status_codes import _codes as codes
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
try:
from tqdm import tqdm
except ImportError:
@ -718,3 +718,14 @@ _illegal_XML_re = re.compile(fr'[{"".join(_illegal_XML_ranges)}]')
def cleanXMLString(s):
return _illegal_XML_re.sub('', s)
def parseXMLString(s: str):
""" Parse an XML string and return an ElementTree object. """
if not s.strip():
return None
try: # Attempt to parse the string as-is without cleaning (which is expensive)
return ElementTree.fromstring(s.encode('utf-8'))
except ElementTree.ParseError: # If it fails, clean the string and try again
cleaned_s = cleanXMLString(s).encode('utf-8')
return ElementTree.fromstring(cleaned_s) if cleaned_s.strip() else None

View file

@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
import os
from functools import cached_property
from pathlib import Path
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 +47,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 +69,14 @@ class Video(PlexPartialObject, PlayedUnplayedMixin):
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 +398,86 @@ class Movie(
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 +622,67 @@ class Show(
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 +835,8 @@ class Season(
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 +848,31 @@ class Season(
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 +1033,8 @@ class Episode(
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 +1044,16 @@ class Episode(
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,7 +1063,55 @@ class Episode(
self._parentRatingKey = utils.cast(int, data.attrib.get('parentRatingKey'))
self._parentThumb = data.attrib.get('parentThumb')
@cached_property
@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_data_property
def parentKey(self):
""" Returns the parentKey. Refer to the Episode attributes. """
if self._parentKey:
@ -993,7 +1120,7 @@ class Episode(
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:
@ -1006,7 +1133,7 @@ class Episode(
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:
@ -1015,7 +1142,7 @@ class Episode(
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:
@ -1055,7 +1182,7 @@ class Episode(
""" 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):
@ -1149,12 +1276,10 @@ class Clip(
""" 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 +1288,10 @@ class Clip(
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