Update plexapi==4.15.11

This commit is contained in:
JonnyWong16 2024-03-31 16:11:00 -07:00
parent 653a6d5c12
commit 85519b1b45
No known key found for this signature in database
GPG key ID: B1F1F9807184697A
8 changed files with 173 additions and 16 deletions

View file

@ -1,4 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations
import os import os
from pathlib import Path from pathlib import Path
from urllib.parse import quote_plus from urllib.parse import quote_plus
@ -17,6 +19,7 @@ from plexapi.playlist import Playlist
TAudio = TypeVar("TAudio", bound="Audio") TAudio = TypeVar("TAudio", bound="Audio")
TTrack = TypeVar("TTrack", bound="Track")
class Audio(PlexPartialObject, PlayedUnplayedMixin): class Audio(PlexPartialObject, PlayedUnplayedMixin):
@ -532,6 +535,22 @@ class Track(
guid_hash = utils.sha1hash(self.parentGuid) guid_hash = utils.sha1hash(self.parentGuid)
return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle') return str(Path('Metadata') / 'Albums' / guid_hash[0] / f'{guid_hash[1:]}.bundle')
def sonicAdventure(
self: TTrack,
to: TTrack,
**kwargs: Any,
) -> list[TTrack]:
"""Returns a sonic adventure from the current track to the specified track.
Parameters:
to (:class:`~plexapi.audio.Track`): The target track for the sonic adventure.
**kwargs: Additional options passed into :func:`~plexapi.library.MusicSection.sonicAdventure`.
Returns:
List[:class:`~plexapi.audio.Track`]: list of tracks in the sonic adventure.
"""
return self.section().sonicAdventure(self, to, **kwargs)
@utils.registerPlexObject @utils.registerPlexObject
class TrackSession(PlexSession, Track): class TrackSession(PlexSession, Track):

View file

@ -1,13 +1,21 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import re import re
from typing import TYPE_CHECKING, Generic, Iterable, List, Optional, TypeVar, Union
import weakref import weakref
from functools import cached_property from functools import cached_property
from urllib.parse import urlencode from urllib.parse import urlencode
from xml.etree import ElementTree from xml.etree import ElementTree
from xml.etree.ElementTree import Element
from plexapi import CONFIG, X_PLEX_CONTAINER_SIZE, log, utils from plexapi import CONFIG, X_PLEX_CONTAINER_SIZE, log, utils
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
if TYPE_CHECKING:
from plexapi.server import PlexServer
PlexObjectT = TypeVar("PlexObjectT", bound='PlexObject')
MediaContainerT = TypeVar("MediaContainerT", bound="MediaContainer")
USER_DONT_RELOAD_FOR_KEYS = set() USER_DONT_RELOAD_FOR_KEYS = set()
_DONT_RELOAD_FOR_KEYS = {'key'} _DONT_RELOAD_FOR_KEYS = {'key'}
OPERATORS = { OPERATORS = {
@ -95,7 +103,7 @@ class PlexObject:
ehash = f"{ehash}.session" ehash = f"{ehash}.session"
elif initpath.startswith('/status/sessions/history'): elif initpath.startswith('/status/sessions/history'):
ehash = f"{ehash}.history" ehash = f"{ehash}.history"
ecls = utils.PLEXOBJECTS.get(ehash, utils.PLEXOBJECTS.get(elem.tag)) ecls = utils.getPlexObject(ehash, default=elem.tag)
# log.debug('Building %s as %s', elem.tag, ecls.__name__) # log.debug('Building %s as %s', elem.tag, ecls.__name__)
if ecls is not None: if ecls is not None:
return ecls(self._server, elem, initpath, parent=self) return ecls(self._server, elem, initpath, parent=self)
@ -116,14 +124,22 @@ class PlexObject:
or disable each parameter individually by setting it to False or 0. or disable each parameter individually by setting it to False or 0.
""" """
details_key = self.key details_key = self.key
params = {}
if details_key and hasattr(self, '_INCLUDES'): if details_key and hasattr(self, '_INCLUDES'):
includes = {}
for k, v in self._INCLUDES.items(): for k, v in self._INCLUDES.items():
value = kwargs.get(k, v) value = kwargs.pop(k, v)
if value not in [False, 0, '0']: if value not in [False, 0, '0']:
includes[k] = 1 if value is True else value params[k] = 1 if value is True else value
if includes:
details_key += '?' + urlencode(sorted(includes.items())) if details_key and hasattr(self, '_EXCLUDES'):
for k, v in self._EXCLUDES.items():
value = kwargs.pop(k, None)
if value is not None:
params[k] = 1 if value is True else value
if params:
details_key += '?' + urlencode(sorted(params.items()))
return details_key return details_key
def _isChildOf(self, **kwargs): def _isChildOf(self, **kwargs):
@ -227,7 +243,7 @@ class PlexObject:
fetchItem(ekey, viewCount__gte=0) fetchItem(ekey, viewCount__gte=0)
fetchItem(ekey, Media__container__in=["mp4", "mkv"]) fetchItem(ekey, Media__container__in=["mp4", "mkv"])
fetchItem(ekey, guid__regex=r"com\.plexapp\.agents\.(imdb|themoviedb)://|tt\d+") fetchItem(ekey, guid__regex=r"com\\.plexapp\\.agents\\.(imdb|themoviedb)://|tt\d+")
fetchItem(ekey, guid__id__regex=r"(imdb|tmdb|tvdb)://") fetchItem(ekey, guid__id__regex=r"(imdb|tmdb|tvdb)://")
fetchItem(ekey, Media__Part__file__startswith="D:\\Movies") fetchItem(ekey, Media__Part__file__startswith="D:\\Movies")
@ -245,8 +261,7 @@ class PlexObject:
if maxresults is not None: if maxresults is not None:
container_size = min(container_size, maxresults) container_size = min(container_size, maxresults)
results = [] results = MediaContainer[cls](self._server, Element('MediaContainer'), initpath=ekey)
subresults = []
headers = {} headers = {}
while True: while True:
@ -324,7 +339,7 @@ class PlexObject:
if rtag: if rtag:
data = next(utils.iterXMLBFS(data, rtag), []) data = next(utils.iterXMLBFS(data, rtag), [])
# loop through all data elements to find matches # loop through all data elements to find matches
items = [] items = MediaContainer[cls](self._server, data, initpath=initpath) if data.tag == 'MediaContainer' else []
for elem in data: for elem in data:
if self._checkAttrs(elem, **kwargs): if self._checkAttrs(elem, **kwargs):
item = self._buildItemOrNone(elem, cls, initpath) item = self._buildItemOrNone(elem, cls, initpath)
@ -498,7 +513,14 @@ class PlexPartialObject(PlexObject):
'includeRelated': 1, 'includeRelated': 1,
'includeRelatedCount': 1, 'includeRelatedCount': 1,
'includeReviews': 1, 'includeReviews': 1,
'includeStations': 1 'includeStations': 1,
}
_EXCLUDES = {
'excludeElements': (
'Media,Genre,Country,Guid,Rating,Collection,Director,Writer,Role,Producer,Similar,Style,Mood,Format'
),
'excludeFields': 'summary,tagline',
'skipRefresh': 1,
} }
def __eq__(self, other): def __eq__(self, other):
@ -996,7 +1018,11 @@ class PlexHistory(object):
return self._server.query(self.historyKey, method=self._server._session.delete) return self._server.query(self.historyKey, method=self._server._session.delete)
class MediaContainer(PlexObject): class MediaContainer(
Generic[PlexObjectT],
List[PlexObjectT],
PlexObject,
):
""" Represents a single MediaContainer. """ Represents a single MediaContainer.
Attributes: Attributes:
@ -1009,11 +1035,71 @@ class MediaContainer(PlexObject):
librarySectionUUID (str): :class:`~plexapi.library.LibrarySection` UUID. librarySectionUUID (str): :class:`~plexapi.library.LibrarySection` UUID.
mediaTagPrefix (str): "/system/bundle/media/flags/" mediaTagPrefix (str): "/system/bundle/media/flags/"
mediaTagVersion (int): Unknown mediaTagVersion (int): Unknown
offset (int): The offset of current results.
size (int): The number of items in the hub. size (int): The number of items in the hub.
totalSize (int): The total number of items for the query.
""" """
TAG = 'MediaContainer' TAG = 'MediaContainer'
def __init__(
self,
server: "PlexServer",
data: Element,
*args: PlexObjectT,
initpath: Optional[str] = None,
parent: Optional[PlexObject] = None,
) -> None:
# super calls Generic.__init__ which calls list.__init__ eventually
super().__init__(*args)
PlexObject.__init__(self, server, data, initpath, parent)
def extend(
self: MediaContainerT,
__iterable: Union[Iterable[PlexObjectT], MediaContainerT],
) -> None:
curr_size = self.size if self.size is not None else len(self)
super().extend(__iterable)
# update size, totalSize, and offset
if not isinstance(__iterable, MediaContainer):
return
# prefer the totalSize of the new iterable even if it is smaller
self.totalSize = (
__iterable.totalSize
if __iterable.totalSize is not None
else self.totalSize
) # ideally both should be equal
# the size of the new iterable is added to the current size
self.size = curr_size + (
__iterable.size if __iterable.size is not None else len(__iterable)
)
# the offset is the minimum of the two, prefering older values
if self.offset is not None and __iterable.offset is not None:
self.offset = min(self.offset, __iterable.offset)
else:
self.offset = (
self.offset if self.offset is not None else __iterable.offset
)
# for all other attributes, overwrite with the new iterable's values if previously None
for key in (
"allowSync",
"augmentationKey",
"identifier",
"librarySectionID",
"librarySectionTitle",
"librarySectionUUID",
"mediaTagPrefix",
"mediaTagVersion",
):
if (not hasattr(self, key)) or (getattr(self, key) is None):
if not hasattr(__iterable, key):
continue
setattr(self, key, getattr(__iterable, key))
def _loadData(self, data): def _loadData(self, data):
self._data = data self._data = data
self.allowSync = utils.cast(int, data.attrib.get('allowSync')) self.allowSync = utils.cast(int, data.attrib.get('allowSync'))
@ -1024,4 +1110,6 @@ class MediaContainer(PlexObject):
self.librarySectionUUID = data.attrib.get('librarySectionUUID') self.librarySectionUUID = data.attrib.get('librarySectionUUID')
self.mediaTagPrefix = data.attrib.get('mediaTagPrefix') self.mediaTagPrefix = data.attrib.get('mediaTagPrefix')
self.mediaTagVersion = data.attrib.get('mediaTagVersion') self.mediaTagVersion = data.attrib.get('mediaTagVersion')
self.offset = utils.cast(int, data.attrib.get("offset"))
self.size = utils.cast(int, data.attrib.get('size')) self.size = utils.cast(int, data.attrib.get('size'))
self.totalSize = utils.cast(int, data.attrib.get("totalSize"))

View file

@ -503,6 +503,8 @@ class Collection(
:class:`~plexapi.collection.Collection`: A new instance of the created Collection. :class:`~plexapi.collection.Collection`: A new instance of the created Collection.
""" """
if smart: if smart:
if items:
raise BadRequest('Cannot create a smart collection with items.')
return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs) return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs)
else: else:
return cls._create(server, title, section, items) return cls._create(server, title, section, items)

View file

@ -4,6 +4,6 @@
# Library version # Library version
MAJOR_VERSION = 4 MAJOR_VERSION = 4
MINOR_VERSION = 15 MINOR_VERSION = 15
PATCH_VERSION = 10 PATCH_VERSION = 11
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}"

View file

@ -1,5 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import annotations
import re import re
from typing import Any, TYPE_CHECKING
import warnings import warnings
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
@ -17,6 +20,10 @@ from plexapi.settings import Setting
from plexapi.utils import deprecated from plexapi.utils import deprecated
if TYPE_CHECKING:
from plexapi.audio import Track
class Library(PlexObject): class Library(PlexObject):
""" Represents a PlexServer library. This contains all sections of media defined """ Represents a PlexServer library. This contains all sections of media defined
in your Plex server including video, shows and audio. in your Plex server including video, shows and audio.
@ -376,7 +383,8 @@ class Library(PlexObject):
part = (f'/library/sections?name={quote_plus(name)}&type={type}&agent={agent}' part = (f'/library/sections?name={quote_plus(name)}&type={type}&agent={agent}'
f'&scanner={quote_plus(scanner)}&language={language}&{urlencode(locations, doseq=True)}') f'&scanner={quote_plus(scanner)}&language={language}&{urlencode(locations, doseq=True)}')
if kwargs: if kwargs:
part += urlencode(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) return self._server.query(part, method=self._server._session.post)
def history(self, maxresults=None, mindate=None): def history(self, maxresults=None, mindate=None):
@ -655,7 +663,7 @@ class LibrarySection(PlexObject):
guidLookup = {} guidLookup = {}
for item in library.all(): for item in library.all():
guidLookup[item.guid] = item guidLookup[item.guid] = item
guidLookup.update({guid.id: item for guid in item.guids}} guidLookup.update({guid.id: item for guid in item.guids})
result1 = guidLookup['plex://show/5d9c086c46115600200aa2fe'] result1 = guidLookup['plex://show/5d9c086c46115600200aa2fe']
result2 = guidLookup['imdb://tt0944947'] result2 = guidLookup['imdb://tt0944947']
@ -2033,6 +2041,31 @@ class MusicSection(LibrarySection, ArtistEditMixins, AlbumEditMixins, TrackEditM
kwargs['policy'] = Policy.create(limit) kwargs['policy'] = Policy.create(limit)
return super(MusicSection, self).sync(**kwargs) return super(MusicSection, self).sync(**kwargs)
def sonicAdventure(
self,
start: Track | int,
end: Track | int,
**kwargs: Any,
) -> list[Track]:
""" Returns a list of tracks from this library section that are part of a sonic adventure.
ID's should be of a track, other ID's will return an empty list or items itself or an error.
Parameters:
start (Track | int): The :class:`~plexapi.audio.Track` or ID of the first track in the sonic adventure.
end (Track | int): The :class:`~plexapi.audio.Track` or ID of the last track in the sonic adventure.
kwargs: Additional parameters to pass to :func:`~plexapi.base.PlexObject.fetchItems`.
Returns:
List[:class:`~plexapi.audio.Track`]: a list of tracks from this library section
that are part of a sonic adventure.
"""
# can not use Track due to circular import
startID = start if isinstance(start, int) else start.ratingKey
endID = end if isinstance(end, int) else end.ratingKey
key = f"/library/sections/{self.key}/computePath?startID={startID}&endID={endID}"
return self.fetchItems(key, **kwargs)
class PhotoSection(LibrarySection, PhotoalbumEditMixins, PhotoEditMixins): class PhotoSection(LibrarySection, PhotoalbumEditMixins, PhotoEditMixins):
""" Represents a :class:`~plexapi.library.LibrarySection` section containing photos. """ Represents a :class:`~plexapi.library.LibrarySection` section containing photos.

View file

@ -434,6 +434,8 @@ class Playlist(
if m3ufilepath: if m3ufilepath:
return cls._createFromM3U(server, title, section, m3ufilepath) return cls._createFromM3U(server, title, section, m3ufilepath)
elif smart: elif smart:
if items:
raise BadRequest('Cannot create a smart playlist with items.')
return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs) return cls._createSmart(server, title, section, limit, libtype, sort, filters, **kwargs)
else: else:
return cls._create(server, title, items) return cls._create(server, title, items)

View file

@ -136,6 +136,19 @@ def registerPlexObject(cls):
return cls return cls
def getPlexObject(ehash, default):
""" Return the PlexObject class for the specified ehash. This recursively looks up the class
with the highest specificity, falling back to the default class if not found.
"""
cls = PLEXOBJECTS.get(ehash)
if cls is not None:
return cls
if '.' in ehash:
ehash = ehash.rsplit('.', 1)[0]
return getPlexObject(ehash, default=default)
return PLEXOBJECTS.get(default)
def cast(func, value): def cast(func, value):
""" Cast the specified value to the specified type (returned by func). Currently this """ Cast the specified value to the specified type (returned by func). Currently this
only support str, int, float, bool. Should be extended if needed. only support str, int, float, bool. Should be extended if needed.

View file

@ -28,7 +28,7 @@ musicbrainzngs==0.7.1
packaging==24.0 packaging==24.0
paho-mqtt==2.0.0 paho-mqtt==2.0.0
platformdirs==4.2.0 platformdirs==4.2.0
plexapi==4.15.10 plexapi==4.15.11
portend==3.2.0 portend==3.2.0
profilehooks==1.12.0 profilehooks==1.12.0
PyJWT==2.8.0 PyJWT==2.8.0