2017-02-04 19:46:51 +00:00
|
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
|
import re
|
2024-03-31 22:48:24 +00:00
|
|
|
|
from typing import TYPE_CHECKING, Generic, Iterable, List, Optional, TypeVar, Union
|
2020-12-06 06:39:13 +00:00
|
|
|
|
import weakref
|
2023-07-28 03:05:40 +00:00
|
|
|
|
from functools import cached_property
|
2022-07-21 03:03:20 +00:00
|
|
|
|
from urllib.parse import urlencode
|
2021-05-15 18:25:26 +00:00
|
|
|
|
from xml.etree import ElementTree
|
2024-03-31 22:48:24 +00:00
|
|
|
|
from xml.etree.ElementTree import Element
|
2017-07-18 15:59:23 +00:00
|
|
|
|
|
2023-07-28 01:24:04 +00:00
|
|
|
|
from plexapi import CONFIG, X_PLEX_CONTAINER_SIZE, log, utils
|
2017-02-10 23:16:23 +00:00
|
|
|
|
from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported
|
2017-02-09 06:54:38 +00:00
|
|
|
|
|
2024-03-31 22:48:24 +00:00
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from plexapi.server import PlexServer
|
|
|
|
|
|
|
|
|
|
PlexObjectT = TypeVar("PlexObjectT", bound='PlexObject')
|
|
|
|
|
MediaContainerT = TypeVar("MediaContainerT", bound="MediaContainer")
|
|
|
|
|
|
2021-05-30 23:25:25 +00:00
|
|
|
|
USER_DONT_RELOAD_FOR_KEYS = set()
|
2022-07-21 03:03:20 +00:00
|
|
|
|
_DONT_RELOAD_FOR_KEYS = {'key'}
|
2017-02-09 06:54:38 +00:00
|
|
|
|
OPERATORS = {
|
2017-02-10 23:16:23 +00:00
|
|
|
|
'exact': lambda v, q: v == q,
|
|
|
|
|
'iexact': lambda v, q: v.lower() == q.lower(),
|
|
|
|
|
'contains': lambda v, q: q in v,
|
|
|
|
|
'icontains': lambda v, q: q.lower() in v.lower(),
|
2017-10-13 23:46:09 +00:00
|
|
|
|
'ne': lambda v, q: v != q,
|
2017-02-10 23:16:23 +00:00
|
|
|
|
'in': lambda v, q: v in q,
|
|
|
|
|
'gt': lambda v, q: v > q,
|
|
|
|
|
'gte': lambda v, q: v >= q,
|
|
|
|
|
'lt': lambda v, q: v < q,
|
|
|
|
|
'lte': lambda v, q: v <= q,
|
|
|
|
|
'startswith': lambda v, q: v.startswith(q),
|
2024-02-17 22:35:53 +00:00
|
|
|
|
'istartswith': lambda v, q: v.lower().startswith(q.lower()),
|
2017-02-10 23:16:23 +00:00
|
|
|
|
'endswith': lambda v, q: v.endswith(q),
|
2024-02-17 22:35:53 +00:00
|
|
|
|
'iendswith': lambda v, q: v.lower().endswith(q.lower()),
|
2017-02-13 03:38:56 +00:00
|
|
|
|
'exists': lambda v, q: v is not None if q else v is None,
|
2024-02-17 22:35:25 +00:00
|
|
|
|
'regex': lambda v, q: bool(re.search(q, v)),
|
|
|
|
|
'iregex': lambda v, q: bool(re.search(q, v, flags=re.IGNORECASE)),
|
2017-02-09 06:54:38 +00:00
|
|
|
|
}
|
2017-02-04 19:46:51 +00:00
|
|
|
|
|
|
|
|
|
|
2022-05-30 16:05:00 +00:00
|
|
|
|
class PlexObject:
|
2017-02-07 06:20:49 +00:00
|
|
|
|
""" Base class for all Plex objects.
|
|
|
|
|
|
2017-02-13 02:55:55 +00:00
|
|
|
|
Parameters:
|
|
|
|
|
server (:class:`~plexapi.server.PlexServer`): PlexServer this client is connected to (optional)
|
|
|
|
|
data (ElementTree): Response from PlexServer used to build this object (optional).
|
|
|
|
|
initpath (str): Relative path requested when retrieving specified `data` (optional).
|
2020-12-06 06:57:25 +00:00
|
|
|
|
parent (:class:`~plexapi.base.PlexObject`): The parent object that this object is built from (optional).
|
2017-02-13 02:55:55 +00:00
|
|
|
|
"""
|
|
|
|
|
TAG = None # xml element tag
|
|
|
|
|
TYPE = None # xml element type
|
|
|
|
|
key = None # plex relative url
|
|
|
|
|
|
2020-12-06 06:39:13 +00:00
|
|
|
|
def __init__(self, server, data, initpath=None, parent=None):
|
2017-02-13 02:55:55 +00:00
|
|
|
|
self._server = server
|
|
|
|
|
self._data = data
|
|
|
|
|
self._initpath = initpath or self.key
|
2021-06-06 20:51:59 +00:00
|
|
|
|
self._parent = weakref.ref(parent) if parent is not None else None
|
2021-02-05 02:56:21 +00:00
|
|
|
|
self._details_key = None
|
2023-07-28 01:24:04 +00:00
|
|
|
|
|
|
|
|
|
# Allow overwriting previous attribute values with `None` when manually reloading
|
|
|
|
|
self._overwriteNone = True
|
|
|
|
|
# Automatically reload the object when accessing a missing attribute
|
|
|
|
|
self._autoReload = CONFIG.get('plexapi.autoreload', True, bool)
|
|
|
|
|
# Attribute to save batch edits for a single API call
|
|
|
|
|
self._edits = None
|
|
|
|
|
|
2017-02-13 06:37:23 +00:00
|
|
|
|
if data is not None:
|
|
|
|
|
self._loadData(data)
|
2020-11-21 20:08:27 +00:00
|
|
|
|
self._details_key = self._buildDetailsKey()
|
2017-02-07 06:20:49 +00:00
|
|
|
|
|
|
|
|
|
def __repr__(self):
|
2022-07-21 03:07:39 +00:00
|
|
|
|
uid = self._clean(self.firstAttr('_baseurl', 'ratingKey', 'id', 'key', 'playQueueID', 'uri'))
|
2017-02-23 06:33:30 +00:00
|
|
|
|
name = self._clean(self.firstAttr('title', 'name', 'username', 'product', 'tag', 'value'))
|
2022-08-28 05:56:01 +00:00
|
|
|
|
return f"<{':'.join([p for p in [self.__class__.__name__, uid, name] if p])}>"
|
2017-02-07 06:20:49 +00:00
|
|
|
|
|
|
|
|
|
def __setattr__(self, attr, value):
|
2022-05-17 03:03:06 +00:00
|
|
|
|
overwriteNone = self.__dict__.get('_overwriteNone')
|
|
|
|
|
# Don't overwrite an attr with None unless it's a private variable or overwrite None is True
|
|
|
|
|
if value is not None or attr.startswith('_') or attr not in self.__dict__ or overwriteNone:
|
2017-02-07 06:20:49 +00:00
|
|
|
|
self.__dict__[attr] = value
|
|
|
|
|
|
2017-02-15 05:13:22 +00:00
|
|
|
|
def _clean(self, value):
|
|
|
|
|
""" Clean attr value for display in __repr__. """
|
|
|
|
|
if value:
|
2017-02-15 05:43:48 +00:00
|
|
|
|
value = str(value).replace('/library/metadata/', '')
|
|
|
|
|
value = value.replace('/children', '')
|
2021-01-03 00:44:02 +00:00
|
|
|
|
value = value.replace('/accounts/', '')
|
2021-01-03 02:04:46 +00:00
|
|
|
|
value = value.replace('/devices/', '')
|
2017-02-15 05:13:22 +00:00
|
|
|
|
return value.replace(' ', '-')[:20]
|
|
|
|
|
|
2017-02-13 02:55:55 +00:00
|
|
|
|
def _buildItem(self, elem, cls=None, initpath=None):
|
|
|
|
|
""" Factory function to build objects based on registered PLEXOBJECTS. """
|
|
|
|
|
# cls is specified, build the object and return
|
2017-02-07 06:58:29 +00:00
|
|
|
|
initpath = initpath or self._initpath
|
2017-02-13 02:55:55 +00:00
|
|
|
|
if cls is not None:
|
2020-12-06 06:39:13 +00:00
|
|
|
|
return cls(self._server, elem, initpath, parent=self)
|
2017-02-13 02:55:55 +00:00
|
|
|
|
# cls is not specified, try looking it up in PLEXOBJECTS
|
2021-01-24 20:48:38 +00:00
|
|
|
|
etype = elem.attrib.get('streamType', elem.attrib.get('tagType', elem.attrib.get('type')))
|
2022-08-28 05:56:01 +00:00
|
|
|
|
ehash = f'{elem.tag}.{etype}' if etype else elem.tag
|
2022-07-21 03:03:20 +00:00
|
|
|
|
if initpath == '/status/sessions':
|
2023-07-28 00:45:51 +00:00
|
|
|
|
ehash = f"{ehash}.session"
|
|
|
|
|
elif initpath.startswith('/status/sessions/history'):
|
|
|
|
|
ehash = f"{ehash}.history"
|
2024-03-16 22:03:44 +00:00
|
|
|
|
ecls = utils.getPlexObject(ehash, default=elem.tag)
|
2017-02-20 05:37:00 +00:00
|
|
|
|
# log.debug('Building %s as %s', elem.tag, ecls.__name__)
|
2017-02-13 02:55:55 +00:00
|
|
|
|
if ecls is not None:
|
2024-02-04 18:52:19 +00:00
|
|
|
|
return ecls(self._server, elem, initpath, parent=self)
|
2022-08-28 05:56:01 +00:00
|
|
|
|
raise UnknownType(f"Unknown library type <{elem.tag} type='{etype}'../>")
|
2017-02-13 02:55:55 +00:00
|
|
|
|
|
|
|
|
|
def _buildItemOrNone(self, elem, cls=None, initpath=None):
|
2020-11-23 03:06:30 +00:00
|
|
|
|
""" Calls :func:`~plexapi.base.PlexObject._buildItem` but returns
|
2017-02-07 06:20:49 +00:00
|
|
|
|
None if elem is an unknown type.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
2017-02-13 02:55:55 +00:00
|
|
|
|
return self._buildItem(elem, cls, initpath)
|
2017-02-07 06:20:49 +00:00
|
|
|
|
except UnknownType:
|
|
|
|
|
return None
|
|
|
|
|
|
2020-11-21 20:08:27 +00:00
|
|
|
|
def _buildDetailsKey(self, **kwargs):
|
2020-11-21 03:51:02 +00:00
|
|
|
|
""" Builds the details key with the XML include parameters.
|
|
|
|
|
All parameters are included by default with the option to override each parameter
|
|
|
|
|
or disable each parameter individually by setting it to False or 0.
|
|
|
|
|
"""
|
2020-11-21 20:08:27 +00:00
|
|
|
|
details_key = self.key
|
2024-03-16 22:18:14 +00:00
|
|
|
|
params = {}
|
|
|
|
|
|
2020-12-07 02:55:45 +00:00
|
|
|
|
if details_key and hasattr(self, '_INCLUDES'):
|
2020-11-22 03:52:01 +00:00
|
|
|
|
for k, v in self._INCLUDES.items():
|
2024-03-16 22:18:14 +00:00
|
|
|
|
value = kwargs.pop(k, v)
|
2020-11-21 03:51:02 +00:00
|
|
|
|
if value not in [False, 0, '0']:
|
2024-03-16 22:18:14 +00:00
|
|
|
|
params[k] = 1 if value is True else value
|
|
|
|
|
|
|
|
|
|
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()))
|
2020-11-21 20:08:27 +00:00
|
|
|
|
return details_key
|
2020-11-21 03:51:02 +00:00
|
|
|
|
|
2021-01-24 20:21:56 +00:00
|
|
|
|
def _isChildOf(self, **kwargs):
|
|
|
|
|
""" Returns True if this object is a child of the given attributes.
|
|
|
|
|
This will search the parent objects all the way to the top.
|
2021-01-24 23:13:22 +00:00
|
|
|
|
|
2020-12-06 06:39:13 +00:00
|
|
|
|
Parameters:
|
2021-01-24 20:21:56 +00:00
|
|
|
|
**kwargs (dict): The attributes and values to search for in the parent objects.
|
|
|
|
|
See all possible `**kwargs*` in :func:`~plexapi.base.PlexObject.fetchItem`.
|
2020-12-06 06:39:13 +00:00
|
|
|
|
"""
|
|
|
|
|
obj = self
|
2021-02-07 04:31:07 +00:00
|
|
|
|
while obj and obj._parent is not None:
|
2020-12-06 06:39:13 +00:00
|
|
|
|
obj = obj._parent()
|
2021-02-07 04:31:07 +00:00
|
|
|
|
if obj and obj._checkAttrs(obj._data, **kwargs):
|
2021-01-24 20:21:56 +00:00
|
|
|
|
return True
|
|
|
|
|
return False
|
2020-12-06 06:39:13 +00:00
|
|
|
|
|
2021-05-15 18:25:26 +00:00
|
|
|
|
def _manuallyLoadXML(self, xml, cls=None):
|
|
|
|
|
""" Manually load an XML string as a :class:`~plexapi.base.PlexObject`.
|
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
xml (str): The XML string to load.
|
|
|
|
|
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
|
|
|
|
|
items to be fetched, passing this in will help the parser ensure
|
|
|
|
|
it only returns those items. By default we convert the xml elements
|
|
|
|
|
with the best guess PlexObjects based on tag and type attrs.
|
|
|
|
|
"""
|
|
|
|
|
elem = ElementTree.fromstring(xml)
|
|
|
|
|
return self._buildItemOrNone(elem, cls)
|
|
|
|
|
|
2024-04-19 19:00:24 +00:00
|
|
|
|
def fetchItems(
|
|
|
|
|
self,
|
|
|
|
|
ekey,
|
|
|
|
|
cls=None,
|
|
|
|
|
container_start=None,
|
|
|
|
|
container_size=None,
|
|
|
|
|
maxresults=None,
|
|
|
|
|
params=None,
|
|
|
|
|
**kwargs,
|
|
|
|
|
):
|
2017-02-09 06:54:38 +00:00
|
|
|
|
""" Load the specified key to find and build all items with the specified tag
|
2021-03-12 17:18:36 +00:00
|
|
|
|
and attrs.
|
2020-04-26 19:18:52 +00:00
|
|
|
|
|
2020-04-27 16:22:10 +00:00
|
|
|
|
Parameters:
|
2023-07-27 21:45:23 +00:00
|
|
|
|
ekey (str or List<int>): API URL path in Plex to fetch items from. If a list of ints is passed
|
|
|
|
|
in, the key will be translated to /library/metadata/<key1,key2,key3>. This allows
|
|
|
|
|
fetching multiple items only knowing their key-ids.
|
2021-03-12 17:18:36 +00:00
|
|
|
|
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
|
|
|
|
|
items to be fetched, passing this in will help the parser ensure
|
|
|
|
|
it only returns those items. By default we convert the xml elements
|
|
|
|
|
with the best guess PlexObjects based on tag and type attrs.
|
|
|
|
|
etag (str): Only fetch items with the specified tag.
|
2020-04-27 16:22:10 +00:00
|
|
|
|
container_start (None, int): offset to get a subset of the data
|
|
|
|
|
container_size (None, int): How many items in data
|
2023-05-24 21:50:30 +00:00
|
|
|
|
maxresults (int, optional): Only return the specified number of results.
|
2024-04-19 19:00:24 +00:00
|
|
|
|
params (dict, optional): Any additional params to add to the request.
|
2021-03-12 17:18:36 +00:00
|
|
|
|
**kwargs (dict): Optionally add XML attribute to filter the items.
|
|
|
|
|
See the details below for more info.
|
|
|
|
|
|
|
|
|
|
**Filtering XML Attributes**
|
|
|
|
|
|
|
|
|
|
Any XML attribute can be filtered when fetching results. Filtering is done before
|
|
|
|
|
the Python objects are built to help keep things speedy. For example, passing in
|
|
|
|
|
``viewCount=0`` will only return matching items where the view count is ``0``.
|
2022-02-27 03:26:08 +00:00
|
|
|
|
Note that case matters when specifying attributes. Attributes further down in the XML
|
2021-03-12 17:18:36 +00:00
|
|
|
|
tree can be filtered by *prepending* the attribute with each element tag ``Tag__``.
|
|
|
|
|
|
|
|
|
|
Examples:
|
|
|
|
|
|
|
|
|
|
.. code-block:: python
|
|
|
|
|
|
|
|
|
|
fetchItem(ekey, viewCount=0)
|
|
|
|
|
fetchItem(ekey, contentRating="PG")
|
|
|
|
|
fetchItem(ekey, Genre__tag="Animation")
|
|
|
|
|
fetchItem(ekey, Media__videoCodec="h265")
|
|
|
|
|
fetchItem(ekey, Media__Part__container="mp4)
|
|
|
|
|
|
|
|
|
|
Note that because some attribute names are already used as arguments to this
|
|
|
|
|
function, such as ``tag``, you may still reference the attr tag by prepending an
|
|
|
|
|
underscore. For example, passing in ``_tag='foobar'`` will return all items where
|
|
|
|
|
``tag='foobar'``.
|
|
|
|
|
|
|
|
|
|
**Using PlexAPI Operators**
|
|
|
|
|
|
|
|
|
|
Optionally, PlexAPI operators can be specified by *appending* it to the end of the
|
|
|
|
|
attribute for more complex lookups. For example, passing in ``viewCount__gte=0``
|
|
|
|
|
will return all items where ``viewCount >= 0``.
|
|
|
|
|
|
|
|
|
|
List of Available Operators:
|
|
|
|
|
|
|
|
|
|
* ``__contains``: Value contains specified arg.
|
|
|
|
|
* ``__endswith``: Value ends with specified arg.
|
|
|
|
|
* ``__exact``: Value matches specified arg.
|
|
|
|
|
* ``__exists`` (*bool*): Value is or is not present in the attrs.
|
|
|
|
|
* ``__gt``: Value is greater than specified arg.
|
|
|
|
|
* ``__gte``: Value is greater than or equal to specified arg.
|
2022-02-27 03:26:08 +00:00
|
|
|
|
* ``__icontains``: Case insensitive value contains specified arg.
|
|
|
|
|
* ``__iendswith``: Case insensitive value ends with specified arg.
|
|
|
|
|
* ``__iexact``: Case insensitive value matches specified arg.
|
2021-03-12 17:18:36 +00:00
|
|
|
|
* ``__in``: Value is in a specified list or tuple.
|
2022-02-27 03:26:08 +00:00
|
|
|
|
* ``__iregex``: Case insensitive value matches the specified regular expression.
|
|
|
|
|
* ``__istartswith``: Case insensitive value starts with specified arg.
|
2021-03-12 17:18:36 +00:00
|
|
|
|
* ``__lt``: Value is less than specified arg.
|
|
|
|
|
* ``__lte``: Value is less than or equal to specified arg.
|
|
|
|
|
* ``__regex``: Value matches the specified regular expression.
|
|
|
|
|
* ``__startswith``: Value starts with specified arg.
|
|
|
|
|
|
|
|
|
|
Examples:
|
|
|
|
|
|
|
|
|
|
.. code-block:: python
|
|
|
|
|
|
|
|
|
|
fetchItem(ekey, viewCount__gte=0)
|
|
|
|
|
fetchItem(ekey, Media__container__in=["mp4", "mkv"])
|
2024-04-23 03:57:49 +00:00
|
|
|
|
fetchItem(ekey, guid__regex=r"com\\.plexapp\\.agents\\.(imdb|themoviedb)://|tt\\d+")
|
2024-02-04 19:24:21 +00:00
|
|
|
|
fetchItem(ekey, guid__id__regex=r"(imdb|tmdb|tvdb)://")
|
2021-03-12 17:18:36 +00:00
|
|
|
|
fetchItem(ekey, Media__Part__file__startswith="D:\\Movies")
|
2020-04-27 16:22:10 +00:00
|
|
|
|
|
2017-02-07 06:20:49 +00:00
|
|
|
|
"""
|
2022-07-21 03:03:20 +00:00
|
|
|
|
if ekey is None:
|
|
|
|
|
raise BadRequest('ekey was not provided')
|
|
|
|
|
|
2023-07-27 21:45:23 +00:00
|
|
|
|
if isinstance(ekey, list) and all(isinstance(key, int) for key in ekey):
|
|
|
|
|
ekey = f'/library/metadata/{",".join(str(key) for key in ekey)}'
|
|
|
|
|
|
2023-05-24 21:50:30 +00:00
|
|
|
|
container_start = container_start or 0
|
|
|
|
|
container_size = container_size or X_PLEX_CONTAINER_SIZE
|
|
|
|
|
offset = container_start
|
2020-04-26 19:18:52 +00:00
|
|
|
|
|
2023-05-24 21:50:30 +00:00
|
|
|
|
if maxresults is not None:
|
|
|
|
|
container_size = min(container_size, maxresults)
|
2020-04-26 19:18:52 +00:00
|
|
|
|
|
2024-03-31 22:48:24 +00:00
|
|
|
|
results = MediaContainer[cls](self._server, Element('MediaContainer'), initpath=ekey)
|
2023-05-24 21:50:30 +00:00
|
|
|
|
headers = {}
|
2017-02-13 02:55:55 +00:00
|
|
|
|
|
2023-05-24 21:50:30 +00:00
|
|
|
|
while True:
|
|
|
|
|
headers['X-Plex-Container-Start'] = str(container_start)
|
|
|
|
|
headers['X-Plex-Container-Size'] = str(container_size)
|
|
|
|
|
|
2024-04-19 19:00:24 +00:00
|
|
|
|
data = self._server.query(ekey, headers=headers, params=params)
|
2023-05-24 21:50:30 +00:00
|
|
|
|
subresults = self.findItems(data, cls, ekey, **kwargs)
|
|
|
|
|
total_size = utils.cast(int, data.attrib.get('totalSize') or data.attrib.get('size')) or len(subresults)
|
|
|
|
|
|
|
|
|
|
if not subresults:
|
|
|
|
|
if offset > total_size:
|
|
|
|
|
log.info('container_start is greater than the number of items')
|
|
|
|
|
|
|
|
|
|
librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
|
|
|
|
if librarySectionID:
|
|
|
|
|
for item in subresults:
|
|
|
|
|
item.librarySectionID = librarySectionID
|
|
|
|
|
|
|
|
|
|
results.extend(subresults)
|
|
|
|
|
|
2024-04-19 17:57:54 +00:00
|
|
|
|
container_start += container_size
|
|
|
|
|
|
|
|
|
|
if container_start > total_size:
|
|
|
|
|
break
|
|
|
|
|
|
2023-05-24 21:50:30 +00:00
|
|
|
|
wanted_number_of_items = total_size - offset
|
|
|
|
|
if maxresults is not None:
|
|
|
|
|
wanted_number_of_items = min(maxresults, wanted_number_of_items)
|
|
|
|
|
container_size = min(container_size, wanted_number_of_items - len(results))
|
|
|
|
|
|
|
|
|
|
if wanted_number_of_items <= len(results):
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
return results
|
|
|
|
|
|
|
|
|
|
def fetchItem(self, ekey, cls=None, **kwargs):
|
|
|
|
|
""" Load the specified key to find and build the first item with the
|
|
|
|
|
specified tag and attrs. If no tag or attrs are specified then
|
|
|
|
|
the first item in the result set is returned.
|
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
ekey (str or int): Path in Plex to fetch items from. If an int is passed
|
|
|
|
|
in, the key will be translated to /library/metadata/<key>. This allows
|
|
|
|
|
fetching an item only knowing its key-id.
|
|
|
|
|
cls (:class:`~plexapi.base.PlexObject`): If you know the class of the
|
|
|
|
|
items to be fetched, passing this in will help the parser ensure
|
|
|
|
|
it only returns those items. By default we convert the xml elements
|
|
|
|
|
with the best guess PlexObjects based on tag and type attrs.
|
|
|
|
|
etag (str): Only fetch items with the specified tag.
|
|
|
|
|
**kwargs (dict): Optionally add XML attribute to filter the items.
|
|
|
|
|
See :func:`~plexapi.base.PlexObject.fetchItems` for more details
|
|
|
|
|
on how this is used.
|
2022-07-21 03:03:20 +00:00
|
|
|
|
"""
|
2023-05-24 21:50:30 +00:00
|
|
|
|
if isinstance(ekey, int):
|
|
|
|
|
ekey = f'/library/metadata/{ekey}'
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
return self.fetchItems(ekey, cls, **kwargs)[0]
|
|
|
|
|
except IndexError:
|
|
|
|
|
clsname = cls.__name__ if cls else 'None'
|
|
|
|
|
raise NotFound(f'Unable to find elem: cls={clsname}, attrs={kwargs}') from None
|
2022-07-21 03:03:20 +00:00
|
|
|
|
|
2021-05-25 00:28:11 +00:00
|
|
|
|
def findItems(self, data, cls=None, initpath=None, rtag=None, **kwargs):
|
2017-02-13 02:55:55 +00:00
|
|
|
|
""" Load the specified data to find and build all items with the specified tag
|
|
|
|
|
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
|
|
|
|
on how this is used.
|
|
|
|
|
"""
|
|
|
|
|
# filter on cls attrs if specified
|
|
|
|
|
if cls and cls.TAG and 'tag' not in kwargs:
|
|
|
|
|
kwargs['etag'] = cls.TAG
|
|
|
|
|
if cls and cls.TYPE and 'type' not in kwargs:
|
|
|
|
|
kwargs['type'] = cls.TYPE
|
2022-05-17 02:46:10 +00:00
|
|
|
|
# rtag to iter on a specific root tag using breadth-first search
|
2021-05-25 00:28:11 +00:00
|
|
|
|
if rtag:
|
2024-04-19 19:42:16 +00:00
|
|
|
|
data = next(utils.iterXMLBFS(data, rtag), Element('Empty'))
|
2017-02-13 02:55:55 +00:00
|
|
|
|
# loop through all data elements to find matches
|
2024-03-31 22:48:24 +00:00
|
|
|
|
items = MediaContainer[cls](self._server, data, initpath=initpath) if data.tag == 'MediaContainer' else []
|
2017-02-13 02:55:55 +00:00
|
|
|
|
for elem in data:
|
|
|
|
|
if self._checkAttrs(elem, **kwargs):
|
|
|
|
|
item = self._buildItemOrNone(elem, cls, initpath)
|
|
|
|
|
if item is not None:
|
|
|
|
|
items.append(item)
|
|
|
|
|
return items
|
2017-02-07 06:20:49 +00:00
|
|
|
|
|
2023-05-24 21:50:30 +00:00
|
|
|
|
def findItem(self, data, cls=None, initpath=None, rtag=None, **kwargs):
|
|
|
|
|
""" Load the specified data to find and build the first items with the specified tag
|
|
|
|
|
and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details
|
|
|
|
|
on how this is used.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
return self.findItems(data, cls, initpath, rtag, **kwargs)[0]
|
|
|
|
|
except IndexError:
|
|
|
|
|
return None
|
|
|
|
|
|
2017-02-15 05:13:22 +00:00
|
|
|
|
def firstAttr(self, *attrs):
|
|
|
|
|
""" Return the first attribute in attrs that is not None. """
|
|
|
|
|
for attr in attrs:
|
2021-02-15 03:58:03 +00:00
|
|
|
|
value = getattr(self, attr, None)
|
2017-02-15 05:13:22 +00:00
|
|
|
|
if value is not None:
|
|
|
|
|
return value
|
|
|
|
|
|
2021-06-14 04:57:07 +00:00
|
|
|
|
def listAttrs(self, data, attr, rtag=None, **kwargs):
|
2020-12-24 06:29:26 +00:00
|
|
|
|
""" Return a list of values from matching attribute. """
|
2017-02-13 03:15:47 +00:00
|
|
|
|
results = []
|
2022-05-17 02:46:10 +00:00
|
|
|
|
# rtag to iter on a specific root tag using breadth-first search
|
2021-06-14 04:57:07 +00:00
|
|
|
|
if rtag:
|
2022-05-17 02:46:10 +00:00
|
|
|
|
data = next(utils.iterXMLBFS(data, rtag), [])
|
2017-02-13 03:15:47 +00:00
|
|
|
|
for elem in data:
|
2022-08-28 05:56:01 +00:00
|
|
|
|
kwargs[f'{attr}__exists'] = True
|
2017-02-13 03:15:47 +00:00
|
|
|
|
if self._checkAttrs(elem, **kwargs):
|
|
|
|
|
results.append(elem.attrib.get(attr))
|
|
|
|
|
return results
|
|
|
|
|
|
2021-06-02 15:59:52 +00:00
|
|
|
|
def reload(self, key=None, **kwargs):
|
2020-11-21 03:51:02 +00:00
|
|
|
|
""" Reload the data for this object from self.key.
|
|
|
|
|
|
|
|
|
|
Parameters:
|
2020-11-22 03:52:33 +00:00
|
|
|
|
key (string, optional): Override the key to reload.
|
|
|
|
|
**kwargs (dict): A dictionary of XML include parameters to exclude or override.
|
|
|
|
|
All parameters are included by default with the option to override each parameter
|
|
|
|
|
or disable each parameter individually by setting it to False or 0.
|
|
|
|
|
See :class:`~plexapi.base.PlexPartialObject` for all the available include parameters.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
.. code-block:: python
|
|
|
|
|
|
|
|
|
|
from plexapi.server import PlexServer
|
|
|
|
|
plex = PlexServer('http://localhost:32400', token='xxxxxxxxxxxxxxxxxxxx')
|
|
|
|
|
movie = plex.library.section('Movies').get('Cars')
|
|
|
|
|
|
|
|
|
|
# Partial reload of the movie without the `checkFiles` parameter.
|
|
|
|
|
# Excluding `checkFiles` will prevent the Plex server from reading the
|
|
|
|
|
# file to check if the file still exists and is accessible.
|
|
|
|
|
# The movie object will remain as a partial object.
|
|
|
|
|
movie.reload(checkFiles=False)
|
|
|
|
|
movie.isPartialObject() # Returns True
|
|
|
|
|
|
|
|
|
|
# Full reload of the movie with all include parameters.
|
|
|
|
|
# The movie object will be a full object.
|
|
|
|
|
movie.reload()
|
|
|
|
|
movie.isFullObject() # Returns True
|
|
|
|
|
|
2020-11-21 03:51:02 +00:00
|
|
|
|
"""
|
2021-06-02 15:59:52 +00:00
|
|
|
|
return self._reload(key=key, **kwargs)
|
|
|
|
|
|
2022-05-17 03:03:06 +00:00
|
|
|
|
def _reload(self, key=None, _overwriteNone=True, **kwargs):
|
2021-06-02 15:59:52 +00:00
|
|
|
|
""" Perform the actual reload. """
|
2020-11-21 20:08:27 +00:00
|
|
|
|
details_key = self._buildDetailsKey(**kwargs) if kwargs else self._details_key
|
|
|
|
|
key = key or details_key or self.key
|
2017-02-27 22:16:02 +00:00
|
|
|
|
if not key:
|
2017-02-07 06:20:49 +00:00
|
|
|
|
raise Unsupported('Cannot reload an object not built from a URL.')
|
2017-02-27 22:16:02 +00:00
|
|
|
|
self._initpath = key
|
|
|
|
|
data = self._server.query(key)
|
2022-05-17 03:03:06 +00:00
|
|
|
|
self._overwriteNone = _overwriteNone
|
2017-02-07 06:20:49 +00:00
|
|
|
|
self._loadData(data[0])
|
2022-05-17 03:03:06 +00:00
|
|
|
|
self._overwriteNone = True
|
2017-02-08 07:00:43 +00:00
|
|
|
|
return self
|
2017-02-07 06:20:49 +00:00
|
|
|
|
|
2017-02-09 06:54:38 +00:00
|
|
|
|
def _checkAttrs(self, elem, **kwargs):
|
2017-02-09 21:29:23 +00:00
|
|
|
|
attrsFound = {}
|
|
|
|
|
for attr, query in kwargs.items():
|
|
|
|
|
attr, op, operator = self._getAttrOperator(attr)
|
|
|
|
|
values = self._getAttrValue(elem, attr)
|
2017-02-13 02:55:55 +00:00
|
|
|
|
# special case query in (None, 0, '') to include missing attr
|
|
|
|
|
if op == 'exact' and not values and query in (None, 0, ''):
|
2017-02-09 06:54:38 +00:00
|
|
|
|
return True
|
|
|
|
|
# return if attr were looking for is missing
|
2017-02-09 21:29:23 +00:00
|
|
|
|
attrsFound[attr] = False
|
|
|
|
|
for value in values:
|
2017-02-13 02:55:55 +00:00
|
|
|
|
value = self._castAttrValue(op, query, value)
|
2017-02-09 21:29:23 +00:00
|
|
|
|
if operator(value, query):
|
|
|
|
|
attrsFound[attr] = True
|
|
|
|
|
break
|
2017-02-20 05:37:00 +00:00
|
|
|
|
# log.debug('Checking %s for %s found: %s', elem.tag, kwargs, attrsFound)
|
2017-02-09 21:29:23 +00:00
|
|
|
|
return all(attrsFound.values())
|
|
|
|
|
|
|
|
|
|
def _getAttrOperator(self, attr):
|
|
|
|
|
for op, operator in OPERATORS.items():
|
2022-08-28 05:56:01 +00:00
|
|
|
|
if attr.endswith(f'__{op}'):
|
2017-02-09 21:29:23 +00:00
|
|
|
|
attr = attr.rsplit('__', 1)[0]
|
|
|
|
|
return attr, op, operator
|
|
|
|
|
# default to exact match
|
|
|
|
|
return attr, 'exact', OPERATORS['exact']
|
|
|
|
|
|
|
|
|
|
def _getAttrValue(self, elem, attrstr, results=None):
|
2017-02-20 05:37:00 +00:00
|
|
|
|
# log.debug('Fetching %s in %s', attrstr, elem.tag)
|
2017-02-09 21:29:23 +00:00
|
|
|
|
parts = attrstr.split('__', 1)
|
|
|
|
|
attr = parts[0]
|
|
|
|
|
attrstr = parts[1] if len(parts) == 2 else None
|
|
|
|
|
if attrstr:
|
|
|
|
|
results = [] if results is None else results
|
2024-02-17 22:16:21 +00:00
|
|
|
|
for child in (c for c in elem if c.tag.lower() == attr.lower()):
|
2017-02-09 21:29:23 +00:00
|
|
|
|
results += self._getAttrValue(child, attrstr, results)
|
|
|
|
|
return [r for r in results if r is not None]
|
2017-02-13 02:55:55 +00:00
|
|
|
|
# check were looking for the tag
|
|
|
|
|
if attr.lower() == 'etag':
|
|
|
|
|
return [elem.tag]
|
2022-02-27 03:26:08 +00:00
|
|
|
|
# loop through attrs so we can perform case-insensitive match
|
2017-02-09 21:29:23 +00:00
|
|
|
|
for _attr, value in elem.attrib.items():
|
|
|
|
|
if attr.lower() == _attr.lower():
|
|
|
|
|
return [value]
|
|
|
|
|
return []
|
2017-02-09 06:54:38 +00:00
|
|
|
|
|
2017-02-13 02:55:55 +00:00
|
|
|
|
def _castAttrValue(self, op, query, value):
|
|
|
|
|
if op == 'exists':
|
|
|
|
|
return value
|
|
|
|
|
if isinstance(query, bool):
|
|
|
|
|
return bool(int(value))
|
|
|
|
|
if isinstance(query, int) and '.' in value:
|
|
|
|
|
return float(value)
|
|
|
|
|
if isinstance(query, int):
|
|
|
|
|
return int(value)
|
|
|
|
|
if isinstance(query, float):
|
|
|
|
|
return float(value)
|
|
|
|
|
return value
|
|
|
|
|
|
2017-02-09 06:54:38 +00:00
|
|
|
|
def _loadData(self, data):
|
2017-02-09 06:59:14 +00:00
|
|
|
|
raise NotImplementedError('Abstract method not implemented.')
|
2017-02-09 06:54:38 +00:00
|
|
|
|
|
2022-02-27 05:40:51 +00:00
|
|
|
|
@property
|
|
|
|
|
def _searchType(self):
|
|
|
|
|
return self.TYPE
|
|
|
|
|
|
2017-02-07 06:20:49 +00:00
|
|
|
|
|
|
|
|
|
class PlexPartialObject(PlexObject):
|
|
|
|
|
""" Not all objects in the Plex listings return the complete list of elements
|
|
|
|
|
for the object. This object will allow you to assume each object is complete,
|
|
|
|
|
and if the specified value you request is None it will fetch the full object
|
|
|
|
|
automatically and update itself.
|
|
|
|
|
"""
|
2020-11-22 03:52:01 +00:00
|
|
|
|
_INCLUDES = {
|
|
|
|
|
'checkFiles': 1,
|
|
|
|
|
'includeAllConcerts': 1,
|
|
|
|
|
'includeBandwidths': 1,
|
|
|
|
|
'includeChapters': 1,
|
|
|
|
|
'includeChildren': 1,
|
|
|
|
|
'includeConcerts': 1,
|
|
|
|
|
'includeExternalMedia': 1,
|
|
|
|
|
'includeExtras': 1,
|
2020-11-22 04:02:31 +00:00
|
|
|
|
'includeFields': 'thumbBlurHash,artBlurHash',
|
2020-11-22 03:52:01 +00:00
|
|
|
|
'includeGeolocation': 1,
|
|
|
|
|
'includeLoudnessRamps': 1,
|
|
|
|
|
'includeMarkers': 1,
|
|
|
|
|
'includeOnDeck': 1,
|
|
|
|
|
'includePopularLeaves': 1,
|
|
|
|
|
'includePreferences': 1,
|
|
|
|
|
'includeRelated': 1,
|
|
|
|
|
'includeRelatedCount': 1,
|
|
|
|
|
'includeReviews': 1,
|
2024-03-16 22:18:14 +00:00
|
|
|
|
'includeStations': 1,
|
|
|
|
|
}
|
|
|
|
|
_EXCLUDES = {
|
|
|
|
|
'excludeElements': (
|
|
|
|
|
'Media,Genre,Country,Guid,Rating,Collection,Director,Writer,Role,Producer,Similar,Style,Mood,Format'
|
|
|
|
|
),
|
|
|
|
|
'excludeFields': 'summary,tagline',
|
|
|
|
|
'skipRefresh': 1,
|
2020-11-22 03:52:01 +00:00
|
|
|
|
}
|
2017-07-16 20:46:03 +00:00
|
|
|
|
|
2017-02-07 06:20:49 +00:00
|
|
|
|
def __eq__(self, other):
|
2023-07-27 21:40:14 +00:00
|
|
|
|
if isinstance(other, PlexPartialObject):
|
2023-08-27 20:24:29 +00:00
|
|
|
|
return self.key == other.key
|
2023-07-27 21:40:14 +00:00
|
|
|
|
return NotImplemented
|
2017-02-07 06:20:49 +00:00
|
|
|
|
|
2017-10-05 21:21:14 +00:00
|
|
|
|
def __hash__(self):
|
|
|
|
|
return hash(repr(self))
|
|
|
|
|
|
2017-10-09 14:07:09 +00:00
|
|
|
|
def __iter__(self):
|
|
|
|
|
yield self
|
|
|
|
|
|
2017-02-07 06:20:49 +00:00
|
|
|
|
def __getattribute__(self, attr):
|
2017-02-08 05:36:22 +00:00
|
|
|
|
# Dragons inside.. :-/
|
2017-10-07 20:10:23 +00:00
|
|
|
|
value = super(PlexPartialObject, self).__getattribute__(attr)
|
2022-02-27 03:26:08 +00:00
|
|
|
|
# Check a few cases where we don't want to reload
|
2021-05-30 23:25:25 +00:00
|
|
|
|
if attr in _DONT_RELOAD_FOR_KEYS: return value
|
2021-06-07 15:55:04 +00:00
|
|
|
|
if attr in USER_DONT_RELOAD_FOR_KEYS: return value
|
2019-11-10 03:35:33 +00:00
|
|
|
|
if attr.startswith('_'): return value
|
2017-02-07 06:20:49 +00:00
|
|
|
|
if value not in (None, []): return value
|
|
|
|
|
if self.isFullObject(): return value
|
2023-07-28 00:45:51 +00:00
|
|
|
|
if isinstance(self, (PlexSession, PlexHistory)): return value
|
2022-05-17 03:03:06 +00:00
|
|
|
|
if self._autoReload is False: return value
|
2018-03-02 16:08:10 +00:00
|
|
|
|
# Log the reload.
|
2017-02-07 06:20:49 +00:00
|
|
|
|
clsname = self.__class__.__name__
|
|
|
|
|
title = self.__dict__.get('title', self.__dict__.get('name'))
|
2022-08-28 05:56:01 +00:00
|
|
|
|
objname = f"{clsname} '{title}'" if title else clsname
|
2021-02-24 17:55:53 +00:00
|
|
|
|
log.debug("Reloading %s for attr '%s'", objname, attr)
|
2017-02-07 06:20:49 +00:00
|
|
|
|
# Reload and return the value
|
2022-05-19 19:48:01 +00:00
|
|
|
|
self._reload(_overwriteNone=False)
|
2017-10-07 20:10:23 +00:00
|
|
|
|
return super(PlexPartialObject, self).__getattribute__(attr)
|
|
|
|
|
|
2017-02-09 20:01:23 +00:00
|
|
|
|
def analyze(self):
|
|
|
|
|
""" Tell Plex Media Server to performs analysis on it this item to gather
|
|
|
|
|
information. Analysis includes:
|
|
|
|
|
|
|
|
|
|
* Gather Media Properties: All of the media you add to a Library has
|
|
|
|
|
properties that are useful to know–whether it's a video file, a
|
|
|
|
|
music track, or one of your photos (container, codec, resolution, etc).
|
|
|
|
|
* Generate Default Artwork: Artwork will automatically be grabbed from a
|
|
|
|
|
video file. A background image will be pulled out as well as a
|
|
|
|
|
smaller image to be used for poster/thumbnail type purposes.
|
|
|
|
|
* Generate Video Preview Thumbnails: Video preview thumbnails are created,
|
|
|
|
|
if you have that feature enabled. Video preview thumbnails allow
|
|
|
|
|
graphical seeking in some Apps. It's also used in the Plex Web App Now
|
|
|
|
|
Playing screen to show a graphical representation of where playback
|
|
|
|
|
is. Video preview thumbnails creation is a CPU-intensive process akin
|
|
|
|
|
to transcoding the file.
|
2020-05-25 02:55:52 +00:00
|
|
|
|
* Generate intro video markers: Detects show intros, exposing the
|
|
|
|
|
'Skip Intro' button in clients.
|
2017-02-09 20:01:23 +00:00
|
|
|
|
"""
|
2022-08-28 05:56:01 +00:00
|
|
|
|
key = f"/{self.key.lstrip('/')}/analyze"
|
2017-02-09 20:01:23 +00:00
|
|
|
|
self._server.query(key, method=self._server._session.put)
|
|
|
|
|
|
2017-02-07 06:20:49 +00:00
|
|
|
|
def isFullObject(self):
|
2021-06-06 21:54:15 +00:00
|
|
|
|
""" Returns True if this is already a full object. A full object means all attributes
|
2017-02-07 06:20:49 +00:00
|
|
|
|
were populated from the api path representing only this item. For example, the
|
|
|
|
|
search result for a movie often only contain a portion of the attributes a full
|
2020-11-21 04:34:41 +00:00
|
|
|
|
object (main url) for that movie would contain.
|
2017-02-07 06:20:49 +00:00
|
|
|
|
"""
|
2020-11-21 04:34:41 +00:00
|
|
|
|
return not self.key or (self._details_key or self.key) == self._initpath
|
2017-02-07 06:20:49 +00:00
|
|
|
|
|
|
|
|
|
def isPartialObject(self):
|
|
|
|
|
""" Returns True if this is not a full object. """
|
|
|
|
|
return not self.isFullObject()
|
|
|
|
|
|
2023-11-13 18:56:30 +00:00
|
|
|
|
def isLocked(self, field: str):
|
|
|
|
|
""" Returns True if the specified field is locked, otherwise False.
|
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
field (str): The name of the field.
|
|
|
|
|
"""
|
|
|
|
|
return next((f.locked for f in self.fields if f.name == field), False)
|
|
|
|
|
|
2021-09-13 00:56:21 +00:00
|
|
|
|
def _edit(self, **kwargs):
|
|
|
|
|
""" Actually edit an object. """
|
2022-02-27 05:40:51 +00:00
|
|
|
|
if isinstance(self._edits, dict):
|
|
|
|
|
self._edits.update(kwargs)
|
|
|
|
|
return self
|
|
|
|
|
|
2021-09-13 00:56:21 +00:00
|
|
|
|
if 'type' not in kwargs:
|
2022-02-27 05:40:51 +00:00
|
|
|
|
kwargs['type'] = utils.searchType(self._searchType)
|
2021-09-13 00:56:21 +00:00
|
|
|
|
|
2023-07-27 21:45:23 +00:00
|
|
|
|
self.section()._edit(items=self, **kwargs)
|
2022-02-27 05:40:51 +00:00
|
|
|
|
return self
|
2021-09-13 00:56:21 +00:00
|
|
|
|
|
2017-07-16 20:46:03 +00:00
|
|
|
|
def edit(self, **kwargs):
|
2017-07-30 04:31:45 +00:00
|
|
|
|
""" Edit an object.
|
2022-02-27 05:40:51 +00:00
|
|
|
|
Note: This is a low level method and you need to know all the field/tag keys.
|
|
|
|
|
See :class:`~plexapi.mixins.EditFieldMixin` and :class:`~plexapi.mixins.EditTagsMixin`
|
|
|
|
|
for individual field and tag editing methods.
|
2017-07-16 20:46:03 +00:00
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
kwargs (dict): Dict of settings to edit.
|
|
|
|
|
|
|
|
|
|
Example:
|
2022-02-27 05:40:51 +00:00
|
|
|
|
|
|
|
|
|
.. code-block:: python
|
|
|
|
|
|
|
|
|
|
edits = {
|
|
|
|
|
'type': 1,
|
|
|
|
|
'id': movie.ratingKey,
|
|
|
|
|
'title.value': 'A new title',
|
|
|
|
|
'title.locked': 1,
|
|
|
|
|
'summary.value': 'This is a summary.',
|
|
|
|
|
'summary.locked': 1,
|
|
|
|
|
'collection[0].tag.tag': 'A tag',
|
|
|
|
|
'collection.locked': 1}
|
|
|
|
|
}
|
|
|
|
|
movie.edit(**edits)
|
|
|
|
|
|
2017-07-16 20:46:03 +00:00
|
|
|
|
"""
|
2022-02-27 05:40:51 +00:00
|
|
|
|
return self._edit(**kwargs)
|
|
|
|
|
|
|
|
|
|
def batchEdits(self):
|
|
|
|
|
""" Enable batch editing mode to save API calls.
|
|
|
|
|
Must call :func:`~plexapi.base.PlexPartialObject.saveEdits` at the end to save all the edits.
|
|
|
|
|
See :class:`~plexapi.mixins.EditFieldMixin` and :class:`~plexapi.mixins.EditTagsMixin`
|
|
|
|
|
for individual field and tag editing methods.
|
|
|
|
|
|
|
|
|
|
Example:
|
|
|
|
|
|
|
|
|
|
.. code-block:: python
|
|
|
|
|
|
|
|
|
|
# Batch editing multiple fields and tags in a single API call
|
|
|
|
|
Movie.batchEdits()
|
|
|
|
|
Movie.editTitle('A New Title').editSummary('A new summary').editTagline('A new tagline') \\
|
|
|
|
|
.addCollection('New Collection').removeGenre('Action').addLabel('Favorite')
|
|
|
|
|
Movie.saveEdits()
|
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
self._edits = {}
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def saveEdits(self):
|
2023-09-17 23:01:15 +00:00
|
|
|
|
""" Save all the batch edits. The object needs to be reloaded manually,
|
|
|
|
|
if required.
|
2022-02-27 05:40:51 +00:00
|
|
|
|
See :func:`~plexapi.base.PlexPartialObject.batchEdits` for details.
|
|
|
|
|
"""
|
|
|
|
|
if not isinstance(self._edits, dict):
|
|
|
|
|
raise BadRequest('Batch editing mode not enabled. Must call `batchEdits()` first.')
|
|
|
|
|
|
|
|
|
|
edits = self._edits
|
|
|
|
|
self._edits = None
|
|
|
|
|
self._edit(**edits)
|
2023-09-17 23:01:15 +00:00
|
|
|
|
return self
|
2017-07-16 20:46:03 +00:00
|
|
|
|
|
2017-02-09 20:01:23 +00:00
|
|
|
|
def refresh(self):
|
|
|
|
|
""" Refreshing a Library or individual item causes the metadata for the item to be
|
|
|
|
|
refreshed, even if it already has metadata. You can think of refreshing as
|
|
|
|
|
"update metadata for the requested item even if it already has some". You should
|
|
|
|
|
refresh a Library or individual item if:
|
|
|
|
|
|
|
|
|
|
* You've changed the Library Metadata Agent.
|
|
|
|
|
* You've added "Local Media Assets" (such as artwork, theme music, external
|
|
|
|
|
subtitle files, etc.)
|
|
|
|
|
* You want to freshen the item posters, summary, etc.
|
|
|
|
|
* There's a problem with the poster image that's been downloaded.
|
|
|
|
|
* Items are missing posters or other downloaded information. This is possible if
|
|
|
|
|
the refresh process is interrupted (the Server is turned off, internet
|
|
|
|
|
connection dies, etc).
|
|
|
|
|
"""
|
2022-08-28 05:56:01 +00:00
|
|
|
|
key = f'{self.key}/refresh'
|
2017-02-09 20:01:23 +00:00
|
|
|
|
self._server.query(key, method=self._server._session.put)
|
|
|
|
|
|
|
|
|
|
def section(self):
|
|
|
|
|
""" Returns the :class:`~plexapi.library.LibrarySection` this item belongs to. """
|
|
|
|
|
return self._server.library.sectionByID(self.librarySectionID)
|
|
|
|
|
|
2017-02-10 23:16:23 +00:00
|
|
|
|
def delete(self):
|
2017-07-30 04:31:45 +00:00
|
|
|
|
""" Delete a media element. This has to be enabled under settings > server > library in plex webui. """
|
2017-02-10 23:16:23 +00:00
|
|
|
|
try:
|
|
|
|
|
return self._server.query(self.key, method=self._server._session.delete)
|
|
|
|
|
except BadRequest: # pragma: no cover
|
2017-07-30 04:31:45 +00:00
|
|
|
|
log.error('Failed to delete %s. This could be because you '
|
2021-02-24 17:55:53 +00:00
|
|
|
|
'have not allowed items to be deleted', self.key)
|
2017-02-10 23:16:23 +00:00
|
|
|
|
raise
|
|
|
|
|
|
2023-05-24 21:50:30 +00:00
|
|
|
|
def history(self, maxresults=None, mindate=None):
|
2019-11-20 11:50:25 +00:00
|
|
|
|
""" Get Play History for a media item.
|
2021-08-03 03:37:17 +00:00
|
|
|
|
|
2019-11-20 11:50:25 +00:00
|
|
|
|
Parameters:
|
|
|
|
|
maxresults (int): Only return the specified number of results (optional).
|
|
|
|
|
mindate (datetime): Min datetime to return results from.
|
|
|
|
|
"""
|
|
|
|
|
return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey)
|
2019-11-14 17:21:49 +00:00
|
|
|
|
|
2021-09-26 22:23:09 +00:00
|
|
|
|
def _getWebURL(self, base=None):
|
|
|
|
|
""" Get the Plex Web URL with the correct parameters.
|
|
|
|
|
Private method to allow overriding parameters from subclasses.
|
2021-08-03 03:37:17 +00:00
|
|
|
|
"""
|
2021-09-26 22:23:09 +00:00
|
|
|
|
return self._server._buildWebURL(base=base, endpoint='details', key=self.key)
|
2021-08-03 03:37:17 +00:00
|
|
|
|
|
|
|
|
|
def getWebURL(self, base=None):
|
2021-09-26 22:23:09 +00:00
|
|
|
|
""" Returns the Plex Web URL for a media item.
|
2021-08-03 03:37:17 +00:00
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
base (str): The base URL before the fragment (``#!``).
|
|
|
|
|
Default is https://app.plex.tv/desktop.
|
|
|
|
|
"""
|
2021-09-26 22:23:09 +00:00
|
|
|
|
return self._getWebURL(base=base)
|
2021-08-03 03:37:17 +00:00
|
|
|
|
|
2022-12-21 19:43:26 +00:00
|
|
|
|
def playQueue(self, *args, **kwargs):
|
|
|
|
|
""" Returns a new :class:`~plexapi.playqueue.PlayQueue` from this media item.
|
|
|
|
|
See :func:`~plexapi.playqueue.PlayQueue.create` for available parameters.
|
|
|
|
|
"""
|
|
|
|
|
from plexapi.playqueue import PlayQueue
|
|
|
|
|
return PlayQueue.create(self._server, self, *args, **kwargs)
|
|
|
|
|
|
2017-02-07 06:20:49 +00:00
|
|
|
|
|
2022-05-30 16:05:00 +00:00
|
|
|
|
class Playable:
|
2017-02-04 19:46:51 +00:00
|
|
|
|
""" This is a general place 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.
|
|
|
|
|
|
|
|
|
|
Attributes:
|
2017-02-14 04:32:27 +00:00
|
|
|
|
playlistItemID (int): Playlist item ID (only populated for :class:`~plexapi.playlist.Playlist` items).
|
2020-09-11 21:23:27 +00:00
|
|
|
|
playQueueItemID (int): PlayQueue item ID (only populated for :class:`~plexapi.playlist.PlayQueue` items).
|
2017-02-04 19:46:51 +00:00
|
|
|
|
"""
|
2017-07-16 20:46:03 +00:00
|
|
|
|
|
2017-02-04 19:46:51 +00:00
|
|
|
|
def _loadData(self, data):
|
2017-02-13 06:37:23 +00:00
|
|
|
|
self.playlistItemID = utils.cast(int, data.attrib.get('playlistItemID')) # playlist
|
2020-09-11 21:23:27 +00:00
|
|
|
|
self.playQueueItemID = utils.cast(int, data.attrib.get('playQueueItemID')) # playqueue
|
2017-02-04 19:46:51 +00:00
|
|
|
|
|
2023-03-09 23:48:51 +00:00
|
|
|
|
def getStreamURL(self, **kwargs):
|
2017-02-04 19:46:51 +00:00
|
|
|
|
""" Returns a stream url that may be used by external applications such as VLC.
|
|
|
|
|
|
|
|
|
|
Parameters:
|
2023-03-09 23:48:51 +00:00
|
|
|
|
**kwargs (dict): optional parameters to manipulate the playback when accessing
|
2017-02-04 19:46:51 +00:00
|
|
|
|
the stream. A few known parameters include: maxVideoBitrate, videoResolution
|
2023-03-09 23:48:51 +00:00
|
|
|
|
offset, copyts, protocol, mediaIndex, partIndex, platform.
|
2017-02-04 19:46:51 +00:00
|
|
|
|
|
|
|
|
|
Raises:
|
2020-11-23 20:20:56 +00:00
|
|
|
|
:exc:`~plexapi.exceptions.Unsupported`: When the item doesn't support fetching a stream URL.
|
2017-02-04 19:46:51 +00:00
|
|
|
|
"""
|
2021-06-07 00:50:35 +00:00
|
|
|
|
if self.TYPE not in ('movie', 'episode', 'track', 'clip'):
|
2022-08-28 05:56:01 +00:00
|
|
|
|
raise Unsupported(f'Fetching stream URL for {self.TYPE} is unsupported.')
|
2023-03-09 23:48:51 +00:00
|
|
|
|
|
|
|
|
|
mvb = kwargs.pop('maxVideoBitrate', None)
|
|
|
|
|
vr = kwargs.pop('videoResolution', '')
|
|
|
|
|
protocol = kwargs.pop('protocol', None)
|
|
|
|
|
|
2017-02-04 19:46:51 +00:00
|
|
|
|
params = {
|
|
|
|
|
'path': self.key,
|
2023-03-09 23:48:51 +00:00
|
|
|
|
'mediaIndex': kwargs.pop('mediaIndex', 0),
|
|
|
|
|
'partIndex': kwargs.pop('mediaIndex', 0),
|
|
|
|
|
'protocol': protocol,
|
|
|
|
|
'fastSeek': kwargs.pop('fastSeek', 1),
|
|
|
|
|
'copyts': kwargs.pop('copyts', 1),
|
|
|
|
|
'offset': kwargs.pop('offset', 0),
|
2017-02-04 19:46:51 +00:00
|
|
|
|
'maxVideoBitrate': max(mvb, 64) if mvb else None,
|
2023-03-09 23:48:51 +00:00
|
|
|
|
'videoResolution': vr if re.match(r'^\d+x\d+$', vr) else None,
|
|
|
|
|
'X-Plex-Platform': kwargs.pop('platform', 'Chrome')
|
2017-02-04 19:46:51 +00:00
|
|
|
|
}
|
2023-03-09 23:48:51 +00:00
|
|
|
|
params.update(kwargs)
|
|
|
|
|
|
2017-02-04 19:46:51 +00:00
|
|
|
|
# remove None values
|
|
|
|
|
params = {k: v for k, v in params.items() if v is not None}
|
|
|
|
|
streamtype = 'audio' if self.TYPE in ('track', 'album') else 'video'
|
2023-03-09 23:48:51 +00:00
|
|
|
|
ext = 'mpd' if protocol == 'dash' else 'm3u8'
|
|
|
|
|
|
2022-08-28 05:56:01 +00:00
|
|
|
|
return self._server.url(
|
2023-03-09 23:48:51 +00:00
|
|
|
|
f'/{streamtype}/:/transcode/universal/start.{ext}?{urlencode(params)}',
|
2022-08-28 05:56:01 +00:00
|
|
|
|
includeToken=True
|
|
|
|
|
)
|
2017-02-04 19:46:51 +00:00
|
|
|
|
|
|
|
|
|
def iterParts(self):
|
|
|
|
|
""" Iterates over the parts of this media item. """
|
|
|
|
|
for item in self.media:
|
|
|
|
|
for part in item.parts:
|
|
|
|
|
yield part
|
|
|
|
|
|
2023-12-22 20:33:27 +00:00
|
|
|
|
def videoStreams(self):
|
|
|
|
|
""" Returns a list of :class:`~plexapi.media.videoStream` objects for all MediaParts. """
|
|
|
|
|
if self.isPartialObject():
|
|
|
|
|
self.reload()
|
|
|
|
|
return sum((part.videoStreams() for part in self.iterParts()), [])
|
|
|
|
|
|
|
|
|
|
def audioStreams(self):
|
|
|
|
|
""" Returns a list of :class:`~plexapi.media.AudioStream` objects for all MediaParts. """
|
|
|
|
|
if self.isPartialObject():
|
|
|
|
|
self.reload()
|
|
|
|
|
return sum((part.audioStreams() for part in self.iterParts()), [])
|
|
|
|
|
|
|
|
|
|
def subtitleStreams(self):
|
|
|
|
|
""" Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """
|
|
|
|
|
if self.isPartialObject():
|
|
|
|
|
self.reload()
|
|
|
|
|
return sum((part.subtitleStreams() for part in self.iterParts()), [])
|
|
|
|
|
|
|
|
|
|
def lyricStreams(self):
|
|
|
|
|
""" Returns a list of :class:`~plexapi.media.LyricStream` objects for all MediaParts. """
|
|
|
|
|
if self.isPartialObject():
|
|
|
|
|
self.reload()
|
|
|
|
|
return sum((part.lyricStreams() for part in self.iterParts()), [])
|
|
|
|
|
|
2017-02-04 19:46:51 +00:00
|
|
|
|
def play(self, client):
|
|
|
|
|
""" Start playback on the specified client.
|
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
client (:class:`~plexapi.client.PlexClient`): Client to start playing on.
|
|
|
|
|
"""
|
|
|
|
|
client.playMedia(self)
|
|
|
|
|
|
2019-01-07 13:04:53 +00:00
|
|
|
|
def download(self, savepath=None, keep_original_name=False, **kwargs):
|
2021-11-20 22:16:58 +00:00
|
|
|
|
""" Downloads the media item to the specified location. Returns a list of
|
2017-02-04 19:46:51 +00:00
|
|
|
|
filepaths that have been saved to disk.
|
2017-02-10 23:16:23 +00:00
|
|
|
|
|
2017-02-04 19:46:51 +00:00
|
|
|
|
Parameters:
|
2021-11-20 22:16:58 +00:00
|
|
|
|
savepath (str): Defaults to current working dir.
|
|
|
|
|
keep_original_name (bool): True to keep the original filename otherwise
|
|
|
|
|
a friendlier filename is generated. See filenames below.
|
|
|
|
|
**kwargs (dict): Additional options passed into :func:`~plexapi.audio.Track.getStreamURL`
|
|
|
|
|
to download a transcoded stream, otherwise the media item will be downloaded
|
|
|
|
|
as-is and saved to disk.
|
|
|
|
|
|
|
|
|
|
**Filenames**
|
|
|
|
|
|
|
|
|
|
* Movie: ``<title> (<year>)``
|
|
|
|
|
* Episode: ``<show title> - s00e00 - <episode title>``
|
|
|
|
|
* Track: ``<artist title> - <album title> - 00 - <track title>``
|
|
|
|
|
* Photo: ``<photoalbum title> - <photo/clip title>`` or ``<photo/clip title>``
|
2017-02-04 19:46:51 +00:00
|
|
|
|
"""
|
|
|
|
|
filepaths = []
|
2021-11-20 22:16:58 +00:00
|
|
|
|
parts = [i for i in self.iterParts() if i]
|
|
|
|
|
|
|
|
|
|
for part in parts:
|
|
|
|
|
if not keep_original_name:
|
2022-08-28 05:56:01 +00:00
|
|
|
|
filename = utils.cleanFilename(f'{self._prettyfilename()}.{part.container}')
|
2021-11-20 22:16:58 +00:00
|
|
|
|
else:
|
|
|
|
|
filename = part.file
|
|
|
|
|
|
2017-02-04 19:46:51 +00:00
|
|
|
|
if kwargs:
|
2022-02-27 03:26:08 +00:00
|
|
|
|
# So this seems to be a a lot slower but allows transcode.
|
2017-02-04 19:46:51 +00:00
|
|
|
|
download_url = self.getStreamURL(**kwargs)
|
|
|
|
|
else:
|
2022-08-28 05:56:01 +00:00
|
|
|
|
download_url = self._server.url(f'{part.key}?download=1')
|
2021-11-20 22:16:58 +00:00
|
|
|
|
|
|
|
|
|
filepath = utils.download(
|
|
|
|
|
download_url,
|
|
|
|
|
self._server._token,
|
|
|
|
|
filename=filename,
|
|
|
|
|
savepath=savepath,
|
|
|
|
|
session=self._server._session
|
|
|
|
|
)
|
|
|
|
|
|
2017-02-04 19:46:51 +00:00
|
|
|
|
if filepath:
|
|
|
|
|
filepaths.append(filepath)
|
2021-11-20 22:16:58 +00:00
|
|
|
|
|
2017-02-04 19:46:51 +00:00
|
|
|
|
return filepaths
|
2017-02-26 22:31:09 +00:00
|
|
|
|
|
2018-02-22 11:29:39 +00:00
|
|
|
|
def updateProgress(self, time, state='stopped'):
|
|
|
|
|
""" Set the watched progress for this video.
|
|
|
|
|
|
2022-08-26 19:14:24 +00:00
|
|
|
|
Note that setting the time to 0 will not work.
|
2023-03-10 01:56:40 +00:00
|
|
|
|
Use :func:`~plexapi.mixins.PlayedUnplayedMixin.markPlayed` or
|
|
|
|
|
:func:`~plexapi.mixins.PlayedUnplayedMixin.markUnplayed` to achieve
|
2022-08-26 19:14:24 +00:00
|
|
|
|
that goal.
|
2018-02-22 11:29:39 +00:00
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
time (int): milliseconds watched
|
|
|
|
|
state (string): state of the video, default 'stopped'
|
|
|
|
|
"""
|
2022-08-28 05:56:01 +00:00
|
|
|
|
key = f'/:/progress?key={self.ratingKey}&identifier=com.plexapp.plugins.library&time={time}&state={state}'
|
2018-02-22 11:29:39 +00:00
|
|
|
|
self._server.query(key)
|
2023-05-24 21:50:45 +00:00
|
|
|
|
return self
|
2019-12-31 13:06:56 +00:00
|
|
|
|
|
2018-05-13 08:50:19 +00:00
|
|
|
|
def updateTimeline(self, time, state='stopped', duration=None):
|
2018-05-12 19:29:17 +00:00
|
|
|
|
""" Set the timeline progress for this video.
|
|
|
|
|
|
|
|
|
|
Parameters:
|
|
|
|
|
time (int): milliseconds watched
|
|
|
|
|
state (string): state of the video, default 'stopped'
|
2018-05-13 09:40:15 +00:00
|
|
|
|
duration (int): duration of the item
|
2018-05-12 19:29:17 +00:00
|
|
|
|
"""
|
2018-05-18 08:58:06 +00:00
|
|
|
|
durationStr = '&duration='
|
2018-09-08 15:26:59 +00:00
|
|
|
|
if duration is not None:
|
2018-05-18 08:58:06 +00:00
|
|
|
|
durationStr = durationStr + str(duration)
|
|
|
|
|
else:
|
|
|
|
|
durationStr = durationStr + str(self.duration)
|
2022-11-08 21:40:18 +00:00
|
|
|
|
key = (f'/:/timeline?ratingKey={self.ratingKey}&key={self.key}&'
|
|
|
|
|
f'identifier=com.plexapp.plugins.library&time={int(time)}&state={state}{durationStr}')
|
2018-05-12 19:29:17 +00:00
|
|
|
|
self._server.query(key)
|
2023-05-24 21:50:45 +00:00
|
|
|
|
return self
|
2018-02-22 11:29:39 +00:00
|
|
|
|
|
2017-07-18 15:59:23 +00:00
|
|
|
|
|
2022-07-21 03:03:20 +00:00
|
|
|
|
class PlexSession(object):
|
|
|
|
|
""" This is a general place to store functions specific to media that is a Plex Session.
|
|
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
|
live (bool): True if this is a live tv session.
|
|
|
|
|
player (:class:`~plexapi.client.PlexClient`): PlexClient object for the session.
|
|
|
|
|
session (:class:`~plexapi.media.Session`): Session object for the session
|
|
|
|
|
if the session is using bandwidth (None otherwise).
|
|
|
|
|
sessionKey (int): The session key for the session.
|
|
|
|
|
transcodeSession (:class:`~plexapi.media.TranscodeSession`): TranscodeSession object
|
|
|
|
|
if item is being transcoded (None otherwise).
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _loadData(self, data):
|
|
|
|
|
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 []
|
|
|
|
|
|
2022-12-21 19:51:45 +00:00
|
|
|
|
@cached_property
|
2022-07-21 03:03:20 +00:00
|
|
|
|
def user(self):
|
|
|
|
|
""" Returns the :class:`~plexapi.myplex.MyPlexAccount` object (for admin)
|
|
|
|
|
or :class:`~plexapi.myplex.MyPlexUser` object (for users) for this session.
|
|
|
|
|
"""
|
2022-12-21 19:51:45 +00:00
|
|
|
|
myPlexAccount = self._server.myPlexAccount()
|
|
|
|
|
if self._userId == 1:
|
|
|
|
|
return myPlexAccount
|
|
|
|
|
|
|
|
|
|
return myPlexAccount.user(self._username)
|
2022-07-21 03:03:20 +00:00
|
|
|
|
|
|
|
|
|
def reload(self):
|
|
|
|
|
""" Reload the data for the session.
|
|
|
|
|
Note: This will return the object as-is if the session is no longer active.
|
|
|
|
|
"""
|
|
|
|
|
return self._reload()
|
|
|
|
|
|
|
|
|
|
def _reload(self, _autoReload=False, **kwargs):
|
|
|
|
|
""" Perform the actual reload. """
|
|
|
|
|
# Do not auto reload sessions
|
|
|
|
|
if _autoReload:
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
key = self._initpath
|
|
|
|
|
data = self._server.query(key)
|
|
|
|
|
for elem in data:
|
|
|
|
|
if elem.attrib.get('sessionKey') == str(self.sessionKey):
|
|
|
|
|
self._loadData(elem)
|
|
|
|
|
break
|
|
|
|
|
return self
|
|
|
|
|
|
|
|
|
|
def source(self):
|
|
|
|
|
""" Return the source media object for the session. """
|
|
|
|
|
return self.fetchItem(self._details_key)
|
|
|
|
|
|
|
|
|
|
def stop(self, reason=''):
|
|
|
|
|
""" Stop playback for the session.
|
2023-08-29 03:29:39 +00:00
|
|
|
|
|
2022-07-21 03:03:20 +00:00
|
|
|
|
Parameters:
|
|
|
|
|
reason (str): Message displayed to the user for stopping playback.
|
|
|
|
|
"""
|
|
|
|
|
params = {
|
|
|
|
|
'sessionId': self.session.id,
|
|
|
|
|
'reason': reason,
|
|
|
|
|
}
|
|
|
|
|
key = '/status/sessions/terminate'
|
|
|
|
|
return self._server.query(key, params=params)
|
|
|
|
|
|
|
|
|
|
|
2023-07-28 00:45:51 +00:00
|
|
|
|
class PlexHistory(object):
|
|
|
|
|
""" This is a general place to store functions specific to media that is a Plex history item.
|
|
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
|
accountID (int): The associated :class:`~plexapi.server.SystemAccount` ID.
|
|
|
|
|
deviceID (int): The associated :class:`~plexapi.server.SystemDevice` ID.
|
|
|
|
|
historyKey (str): API URL (/status/sessions/history/<historyID>).
|
|
|
|
|
viewedAt (datetime): Datetime item was last watched.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _loadData(self, data):
|
|
|
|
|
self.accountID = utils.cast(int, data.attrib.get('accountID'))
|
|
|
|
|
self.deviceID = utils.cast(int, data.attrib.get('deviceID'))
|
|
|
|
|
self.historyKey = data.attrib.get('historyKey')
|
|
|
|
|
self.viewedAt = utils.toDatetime(data.attrib.get('viewedAt'))
|
|
|
|
|
|
|
|
|
|
def _reload(self, **kwargs):
|
|
|
|
|
""" Reload the data for the history entry. """
|
|
|
|
|
raise NotImplementedError('History objects cannot be reloaded. Use source() to get the source media item.')
|
|
|
|
|
|
|
|
|
|
def source(self):
|
2023-11-03 04:24:29 +00:00
|
|
|
|
""" Return the source media object for the history entry
|
|
|
|
|
or None if the media no longer exists on the server.
|
|
|
|
|
"""
|
|
|
|
|
return self.fetchItem(self._details_key) if self._details_key else None
|
2023-07-28 00:45:51 +00:00
|
|
|
|
|
|
|
|
|
def delete(self):
|
|
|
|
|
""" Delete the history entry. """
|
|
|
|
|
return self._server.query(self.historyKey, method=self._server._session.delete)
|
|
|
|
|
|
|
|
|
|
|
2024-03-31 22:48:24 +00:00
|
|
|
|
class MediaContainer(
|
|
|
|
|
Generic[PlexObjectT],
|
|
|
|
|
List[PlexObjectT],
|
|
|
|
|
PlexObject,
|
|
|
|
|
):
|
2020-07-15 20:09:05 +00:00
|
|
|
|
""" Represents a single MediaContainer.
|
|
|
|
|
|
|
|
|
|
Attributes:
|
|
|
|
|
TAG (str): 'MediaContainer'
|
2021-05-28 13:30:24 +00:00
|
|
|
|
allowSync (int): Sync/Download is allowed/disallowed for feature.
|
|
|
|
|
augmentationKey (str): API URL (/library/metadata/augmentations/<augmentationKey>).
|
|
|
|
|
identifier (str): "com.plexapp.plugins.library"
|
|
|
|
|
librarySectionID (int): :class:`~plexapi.library.LibrarySection` ID.
|
|
|
|
|
librarySectionTitle (str): :class:`~plexapi.library.LibrarySection` title.
|
|
|
|
|
librarySectionUUID (str): :class:`~plexapi.library.LibrarySection` UUID.
|
|
|
|
|
mediaTagPrefix (str): "/system/bundle/media/flags/"
|
|
|
|
|
mediaTagVersion (int): Unknown
|
2024-03-31 22:48:24 +00:00
|
|
|
|
offset (int): The offset of current results.
|
2021-05-28 13:30:24 +00:00
|
|
|
|
size (int): The number of items in the hub.
|
2024-03-31 22:48:24 +00:00
|
|
|
|
totalSize (int): The total number of items for the query.
|
2020-07-15 20:09:05 +00:00
|
|
|
|
|
|
|
|
|
"""
|
|
|
|
|
TAG = 'MediaContainer'
|
|
|
|
|
|
2024-03-31 22:48:24 +00:00
|
|
|
|
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))
|
|
|
|
|
|
2020-07-15 20:09:05 +00:00
|
|
|
|
def _loadData(self, data):
|
|
|
|
|
self._data = data
|
|
|
|
|
self.allowSync = utils.cast(int, data.attrib.get('allowSync'))
|
|
|
|
|
self.augmentationKey = data.attrib.get('augmentationKey')
|
|
|
|
|
self.identifier = data.attrib.get('identifier')
|
2021-05-28 13:30:14 +00:00
|
|
|
|
self.librarySectionID = utils.cast(int, data.attrib.get('librarySectionID'))
|
|
|
|
|
self.librarySectionTitle = data.attrib.get('librarySectionTitle')
|
|
|
|
|
self.librarySectionUUID = data.attrib.get('librarySectionUUID')
|
2020-07-15 20:09:05 +00:00
|
|
|
|
self.mediaTagPrefix = data.attrib.get('mediaTagPrefix')
|
|
|
|
|
self.mediaTagVersion = data.attrib.get('mediaTagVersion')
|
2024-03-31 22:48:24 +00:00
|
|
|
|
self.offset = utils.cast(int, data.attrib.get("offset"))
|
2020-07-15 20:09:05 +00:00
|
|
|
|
self.size = utils.cast(int, data.attrib.get('size'))
|
2024-03-31 22:48:24 +00:00
|
|
|
|
self.totalSize = utils.cast(int, data.attrib.get("totalSize"))
|