mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-07 13:41:15 -07:00
Update plexapi==4.15.11
This commit is contained in:
parent
653a6d5c12
commit
85519b1b45
8 changed files with 173 additions and 16 deletions
|
@ -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):
|
||||||
|
|
|
@ -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"))
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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}"
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue