mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-12 08:16:06 -07:00
Update PlexAPI to 4.6.3
This commit is contained in:
parent
4ec8ef3ab3
commit
3fe77932a0
9 changed files with 265 additions and 70 deletions
|
@ -6,13 +6,13 @@ from plexapi.base import PlexPartialObject
|
||||||
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
||||||
from plexapi.library import LibrarySection
|
from plexapi.library import LibrarySection
|
||||||
from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin
|
from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin
|
||||||
from plexapi.mixins import LabelMixin
|
from plexapi.mixins import LabelMixin, SmartFilterMixin
|
||||||
from plexapi.playqueue import PlayQueue
|
from plexapi.playqueue import PlayQueue
|
||||||
from plexapi.utils import deprecated
|
from plexapi.utils import deprecated
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin):
|
class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin, SmartFilterMixin):
|
||||||
""" Represents a single Collection.
|
""" Represents a single Collection.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
@ -90,6 +90,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
|
||||||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||||
self._items = None # cache for self.items
|
self._items = None # cache for self.items
|
||||||
self._section = None # cache for self.section
|
self._section = None # cache for self.section
|
||||||
|
self._filters = None # cache for self.filters
|
||||||
|
|
||||||
def __len__(self): # pragma: no cover
|
def __len__(self): # pragma: no cover
|
||||||
return len(self.items())
|
return len(self.items())
|
||||||
|
@ -141,6 +142,15 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
|
||||||
def children(self):
|
def children(self):
|
||||||
return self.items()
|
return self.items()
|
||||||
|
|
||||||
|
def filters(self):
|
||||||
|
""" Returns the search filter dict for smart collection.
|
||||||
|
The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search`
|
||||||
|
to get the list of items.
|
||||||
|
"""
|
||||||
|
if self.smart and self._filters is None:
|
||||||
|
self._filters = self._parseFilters(self.content)
|
||||||
|
return self._filters
|
||||||
|
|
||||||
def section(self):
|
def section(self):
|
||||||
""" Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to.
|
""" Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to.
|
||||||
"""
|
"""
|
||||||
|
@ -277,6 +287,28 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
|
||||||
key = '%s/items/%s' % (self.key, item.ratingKey)
|
key = '%s/items/%s' % (self.key, item.ratingKey)
|
||||||
self._server.query(key, method=self._server._session.delete)
|
self._server.query(key, method=self._server._session.delete)
|
||||||
|
|
||||||
|
def moveItem(self, item, after=None):
|
||||||
|
""" Move an item to a new position in the collection.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||||
|
or :class:`~plexapi.photo.Photo` objects to be moved in the collection.
|
||||||
|
after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||||
|
or :class:`~plexapi.photo.Photo` objects to move the item after in the collection.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
:class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart collection.
|
||||||
|
"""
|
||||||
|
if self.smart:
|
||||||
|
raise BadRequest('Cannot move items in a smart collection.')
|
||||||
|
|
||||||
|
key = '%s/items/%s/move' % (self.key, item.ratingKey)
|
||||||
|
|
||||||
|
if after:
|
||||||
|
key += '?after=%s' % after.ratingKey
|
||||||
|
|
||||||
|
self._server.query(key, method=self._server._session.put)
|
||||||
|
|
||||||
def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs):
|
def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs):
|
||||||
""" Update the filters for a smart collection.
|
""" Update the filters for a smart collection.
|
||||||
|
|
||||||
|
|
|
@ -847,12 +847,6 @@ class LibrarySection(PlexObject):
|
||||||
values = [values]
|
values = [values]
|
||||||
|
|
||||||
fieldType = self.getFieldType(filterField.type)
|
fieldType = self.getFieldType(filterField.type)
|
||||||
choiceTypes = {'tag', 'subtitleLanguage', 'audioLanguage', 'resolution'}
|
|
||||||
if fieldType.type in choiceTypes:
|
|
||||||
filterChoices = self.listFilterChoices(filterField.key, libtype)
|
|
||||||
else:
|
|
||||||
filterChoices = []
|
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -865,11 +859,8 @@ class LibrarySection(PlexObject):
|
||||||
value = float(value) if '.' in str(value) else int(value)
|
value = float(value) if '.' in str(value) else int(value)
|
||||||
elif fieldType.type == 'string':
|
elif fieldType.type == 'string':
|
||||||
value = str(value)
|
value = str(value)
|
||||||
elif fieldType.type in choiceTypes:
|
elif fieldType.type in {'tag', 'subtitleLanguage', 'audioLanguage', 'resolution'}:
|
||||||
value = str((value.id or value.tag) if isinstance(value, media.MediaTag) else value)
|
value = self._validateFieldValueTag(value, filterField, libtype)
|
||||||
matchValue = value.lower()
|
|
||||||
value = next((f.key for f in filterChoices
|
|
||||||
if matchValue in {f.key.lower(), f.title.lower()}), value)
|
|
||||||
results.append(str(value))
|
results.append(str(value))
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
raise BadRequest('Invalid value "%s" for filter field "%s", value should be type %s'
|
raise BadRequest('Invalid value "%s" for filter field "%s", value should be type %s'
|
||||||
|
@ -888,24 +879,47 @@ class LibrarySection(PlexObject):
|
||||||
else:
|
else:
|
||||||
return int(utils.toDatetime(value, '%Y-%m-%d').timestamp())
|
return int(utils.toDatetime(value, '%Y-%m-%d').timestamp())
|
||||||
|
|
||||||
|
def _validateFieldValueTag(self, value, filterField, libtype):
|
||||||
|
""" Validates a filter tag value. A filter tag value can be a :class:`~plexapi.library.FilterChoice` object,
|
||||||
|
a :class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*),
|
||||||
|
or the exact id :attr:`MediaTag.id` (*int*).
|
||||||
|
"""
|
||||||
|
if isinstance(value, FilterChoice):
|
||||||
|
return value.key
|
||||||
|
if isinstance(value, media.MediaTag):
|
||||||
|
value = str(value.id or value.tag)
|
||||||
|
else:
|
||||||
|
value = str(value)
|
||||||
|
filterChoices = self.listFilterChoices(filterField.key, libtype)
|
||||||
|
matchValue = value.lower()
|
||||||
|
return next((f.key for f in filterChoices if matchValue in {f.key.lower(), f.title.lower()}), value)
|
||||||
|
|
||||||
def _validateSortFields(self, sort, libtype=None):
|
def _validateSortFields(self, sort, libtype=None):
|
||||||
""" Validates a list of filter sort fields is available for the library.
|
""" Validates a list of filter sort fields is available for the library. Sort fields can be a
|
||||||
|
list of :class:`~plexapi.library.FilteringSort` objects, or a comma separated string.
|
||||||
Returns the validated comma separated sort fields string.
|
Returns the validated comma separated sort fields string.
|
||||||
"""
|
"""
|
||||||
if isinstance(sort, str):
|
if isinstance(sort, str):
|
||||||
sort = sort.split(',')
|
sort = sort.split(',')
|
||||||
|
|
||||||
|
if not isinstance(sort, (list, tuple)):
|
||||||
|
sort = [sort]
|
||||||
|
|
||||||
validatedSorts = []
|
validatedSorts = []
|
||||||
for _sort in sort:
|
for _sort in sort:
|
||||||
validatedSorts.append(self._validateSortField(_sort.strip(), libtype))
|
validatedSorts.append(self._validateSortField(_sort, libtype))
|
||||||
|
|
||||||
return ','.join(validatedSorts)
|
return ','.join(validatedSorts)
|
||||||
|
|
||||||
def _validateSortField(self, sort, libtype=None):
|
def _validateSortField(self, sort, libtype=None):
|
||||||
""" Validates a filter sort field is available for the library.
|
""" Validates a filter sort field is available for the library. A sort field can be a
|
||||||
|
:class:`~plexapi.library.FilteringSort` object, or a string.
|
||||||
Returns the validated sort field string.
|
Returns the validated sort field string.
|
||||||
"""
|
"""
|
||||||
match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+):?([a-zA-Z]*)', sort)
|
if isinstance(sort, FilteringSort):
|
||||||
|
return '%s.%s:%s' % (libtype or self.TYPE, sort.key, sort.defaultDirection)
|
||||||
|
|
||||||
|
match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+):?([a-zA-Z]*)', sort.strip())
|
||||||
if not match:
|
if not match:
|
||||||
raise BadRequest('Invalid filter sort: %s' % sort)
|
raise BadRequest('Invalid filter sort: %s' % sort)
|
||||||
_libtype, sortField, sortDir = match.groups()
|
_libtype, sortField, sortDir = match.groups()
|
||||||
|
@ -921,16 +935,13 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
sortField = libtype + '.' + filterSort.key
|
sortField = libtype + '.' + filterSort.key
|
||||||
|
|
||||||
if not sortDir:
|
availableDirections = ['', 'asc', 'desc', 'nullsLast']
|
||||||
sortDir = filterSort.defaultDirection
|
|
||||||
|
|
||||||
availableDirections = ['asc', 'desc', 'nullsLast']
|
|
||||||
if sortDir not in availableDirections:
|
if sortDir not in availableDirections:
|
||||||
raise NotFound('Unknown sort direction "%s". '
|
raise NotFound('Unknown sort direction "%s". '
|
||||||
'Available sort directions: %s'
|
'Available sort directions: %s'
|
||||||
% (sortDir, availableDirections))
|
% (sortDir, availableDirections))
|
||||||
|
|
||||||
return '%s:%s' % (sortField, sortDir)
|
return '%s:%s' % (sortField, sortDir) if sortDir else sortField
|
||||||
|
|
||||||
def _validateAdvancedSearch(self, filters, libtype):
|
def _validateAdvancedSearch(self, filters, libtype):
|
||||||
""" Validates an advanced search filter dictionary.
|
""" Validates an advanced search filter dictionary.
|
||||||
|
@ -1009,9 +1020,8 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
title (str, optional): General string query to search for. Partial string matches are allowed.
|
title (str, optional): General string query to search for. Partial string matches are allowed.
|
||||||
sort (str or list, optional): A string of comma separated sort fields or a list of sort fields
|
sort (:class:`~plexapi.library.FilteringSort` or str or list, optional): A field to sort the results.
|
||||||
in the format ``column:dir``.
|
See the details below for more info.
|
||||||
See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields.
|
|
||||||
maxresults (int, optional): Only return the specified number of results.
|
maxresults (int, optional): Only return the specified number of results.
|
||||||
libtype (str, optional): Return results of a specific type (movie, show, season, episode,
|
libtype (str, optional): Return results of a specific type (movie, show, season, episode,
|
||||||
artist, album, track, photoalbum, photo, collection) (e.g. ``libtype='episode'`` will only
|
artist, album, track, photoalbum, photo, collection) (e.g. ``libtype='episode'`` will only
|
||||||
|
@ -1027,6 +1037,28 @@ class LibrarySection(PlexObject):
|
||||||
:exc:`~plexapi.exceptions.BadRequest`: When the sort or filter is invalid.
|
:exc:`~plexapi.exceptions.BadRequest`: When the sort or filter is invalid.
|
||||||
:exc:`~plexapi.exceptions.NotFound`: When applying an unknown sort or filter.
|
:exc:`~plexapi.exceptions.NotFound`: When applying an unknown sort or filter.
|
||||||
|
|
||||||
|
**Sorting Results**
|
||||||
|
|
||||||
|
The search results can be sorted by including the ``sort`` parameter.
|
||||||
|
|
||||||
|
* See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields.
|
||||||
|
|
||||||
|
The ``sort`` parameter can be a :class:`~plexapi.library.FilteringSort` object or a sort string in the
|
||||||
|
format ``field:dir``. The sort direction ``dir`` can be ``asc``, ``desc``, or ``nullsLast``. Omitting the
|
||||||
|
sort direction or using a :class:`~plexapi.library.FilteringSort` object will sort the results in the default
|
||||||
|
direction of the field. Multi-sorting on multiple fields can be achieved by using a comma separated list of
|
||||||
|
sort strings, or a list of :class:`~plexapi.library.FilteringSort` object or strings.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
library.search(sort="titleSort:desc") # Sort title in descending order
|
||||||
|
library.search(sort="titleSort") # Sort title in the default order
|
||||||
|
# Multi-sort by year in descending order, then by audience rating in descending order
|
||||||
|
library.search(sort="year:desc,audienceRating:desc")
|
||||||
|
library.search(sort=["year:desc", "audienceRating:desc"])
|
||||||
|
|
||||||
**Using Plex Filters**
|
**Using Plex Filters**
|
||||||
|
|
||||||
Any of the available custom filters can be applied to the search results
|
Any of the available custom filters can be applied to the search results
|
||||||
|
@ -1065,8 +1097,9 @@ class LibrarySection(PlexObject):
|
||||||
* **writer** (:class:`~plexapi.media.MediaTag`): Search for the name of a writer.
|
* **writer** (:class:`~plexapi.media.MediaTag`): Search for the name of a writer.
|
||||||
* **year** (*int*): Search for a specific year.
|
* **year** (*int*): Search for a specific year.
|
||||||
|
|
||||||
Tag type filter values can be a :class:`~plexapi.media.MediaTag` object, the exact name
|
Tag type filter values can be a :class:`~plexapi.library.FilterChoice` object,
|
||||||
:attr:`MediaTag.tag` (*str*), or the exact id :attr:`MediaTag.id` (*int*).
|
:class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*),
|
||||||
|
or the exact id :attr:`MediaTag.id` (*int*).
|
||||||
|
|
||||||
Date type filter values can be a ``datetime`` object, a relative date using a one of the
|
Date type filter values can be a ``datetime`` object, a relative date using a one of the
|
||||||
available date suffixes (e.g. ``30d``) (*str*), or a date in ``YYYY-MM-DD`` (*str*) format.
|
available date suffixes (e.g. ``30d``) (*str*), or a date in ``YYYY-MM-DD`` (*str*) format.
|
||||||
|
@ -2014,7 +2047,6 @@ class FilteringType(PlexObject):
|
||||||
('guid', 'asc', 'Guid'),
|
('guid', 'asc', 'Guid'),
|
||||||
('id', 'asc', 'Rating Key'),
|
('id', 'asc', 'Rating Key'),
|
||||||
('index', 'asc', '%s Number' % self.type.capitalize()),
|
('index', 'asc', '%s Number' % self.type.capitalize()),
|
||||||
('random', 'asc', 'Random'),
|
|
||||||
('summary', 'asc', 'Summary'),
|
('summary', 'asc', 'Summary'),
|
||||||
('tagline', 'asc', 'Tagline'),
|
('tagline', 'asc', 'Tagline'),
|
||||||
('updatedAt', 'asc', 'Date Updated')
|
('updatedAt', 'asc', 'Date Updated')
|
||||||
|
@ -2029,7 +2061,11 @@ class FilteringType(PlexObject):
|
||||||
additionalSorts.extend([
|
additionalSorts.extend([
|
||||||
('absoluteIndex', 'asc', 'Absolute Index')
|
('absoluteIndex', 'asc', 'Absolute Index')
|
||||||
])
|
])
|
||||||
if self.type == 'collection':
|
elif self.type == 'photo':
|
||||||
|
additionalSorts.extend([
|
||||||
|
('viewUpdatedAt', 'desc', 'View Updated At')
|
||||||
|
])
|
||||||
|
elif self.type == 'collection':
|
||||||
additionalSorts.extend([
|
additionalSorts.extend([
|
||||||
('addedAt', 'asc', 'Date Added')
|
('addedAt', 'asc', 'Date Added')
|
||||||
])
|
])
|
||||||
|
@ -2081,10 +2117,6 @@ class FilteringType(PlexObject):
|
||||||
('rating', 'integer', 'Critic Rating'),
|
('rating', 'integer', 'Critic Rating'),
|
||||||
('viewOffset', 'integer', 'View Offset')
|
('viewOffset', 'integer', 'View Offset')
|
||||||
])
|
])
|
||||||
elif self.type == 'artist':
|
|
||||||
additionalFields.extend([
|
|
||||||
('lastViewedAt', 'date', 'Artist Last Played')
|
|
||||||
])
|
|
||||||
elif self.type == 'track':
|
elif self.type == 'track':
|
||||||
additionalFields.extend([
|
additionalFields.extend([
|
||||||
('duration', 'integer', 'Duration'),
|
('duration', 'integer', 'Duration'),
|
||||||
|
|
|
@ -964,10 +964,12 @@ class Marker(PlexObject):
|
||||||
name = self._clean(self.firstAttr('type'))
|
name = self._clean(self.firstAttr('type'))
|
||||||
start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start')))
|
start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start')))
|
||||||
end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end')))
|
end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end')))
|
||||||
return '<%s:%s %s - %s>' % (self.__class__.__name__, name, start, end)
|
offsets = '%s-%s' % (start, end)
|
||||||
|
return '<%s>' % ':'.join([self.__class__.__name__, name, offsets])
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
self._data = data
|
self._data = data
|
||||||
|
self.id = utils.cast(int, data.attrib.get('id'))
|
||||||
self.type = data.attrib.get('type')
|
self.type = data.attrib.get('type')
|
||||||
self.start = utils.cast(int, data.attrib.get('startTimeOffset'))
|
self.start = utils.cast(int, data.attrib.get('startTimeOffset'))
|
||||||
self.end = utils.cast(int, data.attrib.get('endTimeOffset'))
|
self.end = utils.cast(int, data.attrib.get('endTimeOffset'))
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from urllib.parse import quote_plus, urlencode
|
from urllib.parse import parse_qsl, quote_plus, unquote, urlencode, urlsplit
|
||||||
|
|
||||||
from plexapi import media, settings, utils
|
from plexapi import media, settings, utils
|
||||||
from plexapi.exceptions import BadRequest, NotFound
|
from plexapi.exceptions import BadRequest, NotFound
|
||||||
|
@ -559,3 +559,61 @@ class WriterMixin(object):
|
||||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||||
"""
|
"""
|
||||||
self._edit_tags('writer', writers, locked=locked, remove=True)
|
self._edit_tags('writer', writers, locked=locked, remove=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SmartFilterMixin(object):
|
||||||
|
""" Mixing for Plex objects that can have smart filters. """
|
||||||
|
|
||||||
|
def _parseFilters(self, content):
|
||||||
|
""" Parse the content string and returns the filter dict. """
|
||||||
|
content = urlsplit(unquote(content))
|
||||||
|
filters = {}
|
||||||
|
filterOp = 'and'
|
||||||
|
filterGroups = [[]]
|
||||||
|
|
||||||
|
for key, value in parse_qsl(content.query):
|
||||||
|
# Move = sign to key when operator is ==
|
||||||
|
if value.startswith('='):
|
||||||
|
key += '='
|
||||||
|
value = value[1:]
|
||||||
|
|
||||||
|
if key == 'type':
|
||||||
|
filters['libtype'] = utils.reverseSearchType(value)
|
||||||
|
elif key == 'sort':
|
||||||
|
filters['sort'] = value.split(',')
|
||||||
|
elif key == 'limit':
|
||||||
|
filters['limit'] = int(value)
|
||||||
|
elif key == 'push':
|
||||||
|
filterGroups[-1].append([])
|
||||||
|
filterGroups.append(filterGroups[-1][-1])
|
||||||
|
elif key == 'and':
|
||||||
|
filterOp = 'and'
|
||||||
|
elif key == 'or':
|
||||||
|
filterOp = 'or'
|
||||||
|
elif key == 'pop':
|
||||||
|
filterGroups[-1].insert(0, filterOp)
|
||||||
|
filterGroups.pop()
|
||||||
|
else:
|
||||||
|
filterGroups[-1].append({key: value})
|
||||||
|
|
||||||
|
if filterGroups:
|
||||||
|
filters['filters'] = self._formatFilterGroups(filterGroups.pop())
|
||||||
|
return filters
|
||||||
|
|
||||||
|
def _formatFilterGroups(self, groups):
|
||||||
|
""" Formats the filter groups into the advanced search rules. """
|
||||||
|
if len(groups) == 1 and isinstance(groups[0], list):
|
||||||
|
groups = groups.pop()
|
||||||
|
|
||||||
|
filterOp = 'and'
|
||||||
|
rules = []
|
||||||
|
|
||||||
|
for g in groups:
|
||||||
|
if isinstance(g, list):
|
||||||
|
rules.append(self._formatFilterGroups(g))
|
||||||
|
elif isinstance(g, dict):
|
||||||
|
rules.append(g)
|
||||||
|
elif g in {'and', 'or'}:
|
||||||
|
filterOp = g
|
||||||
|
|
||||||
|
return {filterOp: rules}
|
||||||
|
|
|
@ -623,9 +623,7 @@ class MyPlexAccount(PlexObject):
|
||||||
}
|
}
|
||||||
|
|
||||||
url = SyncList.key.format(clientId=client.clientIdentifier)
|
url = SyncList.key.format(clientId=client.clientIdentifier)
|
||||||
data = self.query(url, method=self._session.post, headers={
|
data = self.query(url, method=self._session.post, params=params)
|
||||||
'Content-type': 'x-www-form-urlencoded',
|
|
||||||
}, params=params)
|
|
||||||
|
|
||||||
return SyncItem(self, data, None, clientIdentifier=client.clientIdentifier)
|
return SyncItem(self, data, None, clientIdentifier=client.clientIdentifier)
|
||||||
|
|
||||||
|
@ -925,6 +923,10 @@ class MyPlexResource(PlexObject):
|
||||||
TAG = 'Device'
|
TAG = 'Device'
|
||||||
key = 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
|
key = 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
|
||||||
|
|
||||||
|
# Default order to prioritize available resource connections
|
||||||
|
DEFAULT_LOCATION_ORDER = ['local', 'remote', 'relay']
|
||||||
|
DEFAULT_SCHEME_ORDER = ['https', 'http']
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
self._data = data
|
self._data = data
|
||||||
self.name = data.attrib.get('name')
|
self.name = data.attrib.get('name')
|
||||||
|
@ -949,10 +951,51 @@ class MyPlexResource(PlexObject):
|
||||||
self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0))
|
self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0))
|
||||||
self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username.
|
self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username.
|
||||||
|
|
||||||
def connect(self, ssl=None, timeout=None):
|
def preferred_connections(
|
||||||
""" Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object.
|
self,
|
||||||
|
ssl=None,
|
||||||
|
timeout=None,
|
||||||
|
locations=DEFAULT_LOCATION_ORDER,
|
||||||
|
schemes=DEFAULT_SCHEME_ORDER,
|
||||||
|
):
|
||||||
|
""" Returns a sorted list of the available connection addresses for this resource.
|
||||||
Often times there is more than one address specified for a server or client.
|
Often times there is more than one address specified for a server or client.
|
||||||
This function will prioritize local connections before remote or relay and HTTPS before HTTP.
|
Default behavior will prioritize local connections before remote or relay and HTTPS before HTTP.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
ssl (bool, optional): Set True to only connect to HTTPS connections. Set False to
|
||||||
|
only connect to HTTP connections. Set None (default) to connect to any
|
||||||
|
HTTP or HTTPS connection.
|
||||||
|
timeout (int, optional): The timeout in seconds to attempt each connection.
|
||||||
|
"""
|
||||||
|
connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations}
|
||||||
|
for connection in self.connections:
|
||||||
|
# Only check non-local connections unless we own the resource
|
||||||
|
if self.owned or (not self.owned and not connection.local):
|
||||||
|
location = 'relay' if connection.relay else ('local' if connection.local else 'remote')
|
||||||
|
if location not in locations:
|
||||||
|
continue
|
||||||
|
if 'http' in schemes:
|
||||||
|
connections_dict[location]['http'].append(connection.httpuri)
|
||||||
|
if 'https' in schemes:
|
||||||
|
connections_dict[location]['https'].append(connection.uri)
|
||||||
|
if ssl is True: schemes.remove('http')
|
||||||
|
elif ssl is False: schemes.remove('https')
|
||||||
|
connections = []
|
||||||
|
for location in locations:
|
||||||
|
for scheme in schemes:
|
||||||
|
connections.extend(connections_dict[location][scheme])
|
||||||
|
return connections
|
||||||
|
|
||||||
|
def connect(
|
||||||
|
self,
|
||||||
|
ssl=None,
|
||||||
|
timeout=None,
|
||||||
|
locations=DEFAULT_LOCATION_ORDER,
|
||||||
|
schemes=DEFAULT_SCHEME_ORDER,
|
||||||
|
):
|
||||||
|
""" Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object.
|
||||||
|
Uses `MyPlexResource.preferred_connections()` to generate the priority order of connection addresses.
|
||||||
After trying to connect to all available addresses for this resource and
|
After trying to connect to all available addresses for this resource and
|
||||||
assuming at least one connection was successful, the PlexServer object is built and returned.
|
assuming at least one connection was successful, the PlexServer object is built and returned.
|
||||||
|
|
||||||
|
@ -965,22 +1008,7 @@ class MyPlexResource(PlexObject):
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
||||||
"""
|
"""
|
||||||
# Keys in the order we want the connections to be sorted
|
connections = self.preferred_connections(ssl, timeout, locations, schemes)
|
||||||
locations = ['local', 'remote', 'relay']
|
|
||||||
schemes = ['https', 'http']
|
|
||||||
connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations}
|
|
||||||
for connection in self.connections:
|
|
||||||
# Only check non-local connections unless we own the resource
|
|
||||||
if self.owned or (not self.owned and not connection.local):
|
|
||||||
location = 'relay' if connection.relay else ('local' if connection.local else 'remote')
|
|
||||||
connections_dict[location]['http'].append(connection.httpuri)
|
|
||||||
connections_dict[location]['https'].append(connection.uri)
|
|
||||||
if ssl is True: schemes.remove('http')
|
|
||||||
elif ssl is False: schemes.remove('https')
|
|
||||||
connections = []
|
|
||||||
for location in locations:
|
|
||||||
for scheme in schemes:
|
|
||||||
connections.extend(connections_dict[location][scheme])
|
|
||||||
# Try connecting to all known resource connections in parellel, but
|
# Try connecting to all known resource connections in parellel, but
|
||||||
# only return the first server (in order) that provides a response.
|
# only return the first server (in order) that provides a response.
|
||||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||||
|
|
|
@ -6,13 +6,13 @@ from plexapi import utils
|
||||||
from plexapi.base import Playable, PlexPartialObject
|
from plexapi.base import Playable, PlexPartialObject
|
||||||
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
||||||
from plexapi.library import LibrarySection
|
from plexapi.library import LibrarySection
|
||||||
from plexapi.mixins import ArtMixin, PosterMixin
|
from plexapi.mixins import ArtMixin, PosterMixin, SmartFilterMixin
|
||||||
from plexapi.playqueue import PlayQueue
|
from plexapi.playqueue import PlayQueue
|
||||||
from plexapi.utils import deprecated
|
from plexapi.utils import deprecated
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
|
class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMixin):
|
||||||
""" Represents a single Playlist.
|
""" Represents a single Playlist.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
@ -61,6 +61,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
|
||||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||||
self._items = None # cache for self.items
|
self._items = None # cache for self.items
|
||||||
self._section = None # cache for self.section
|
self._section = None # cache for self.section
|
||||||
|
self._filters = None # cache for self.filters
|
||||||
|
|
||||||
def __len__(self): # pragma: no cover
|
def __len__(self): # pragma: no cover
|
||||||
return len(self.items())
|
return len(self.items())
|
||||||
|
@ -107,6 +108,22 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
|
||||||
""" Returns True if this is a photo playlist. """
|
""" Returns True if this is a photo playlist. """
|
||||||
return self.playlistType == 'photo'
|
return self.playlistType == 'photo'
|
||||||
|
|
||||||
|
def _getPlaylistItemID(self, item):
|
||||||
|
""" Match an item to a playlist item and return the item playlistItemID. """
|
||||||
|
for _item in self.items():
|
||||||
|
if _item.ratingKey == item.ratingKey:
|
||||||
|
return _item.playlistItemID
|
||||||
|
raise NotFound('Item with title "%s" not found in the playlist' % item.title)
|
||||||
|
|
||||||
|
def filters(self):
|
||||||
|
""" Returns the search filter dict for smart playlist.
|
||||||
|
The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search`
|
||||||
|
to get the list of items.
|
||||||
|
"""
|
||||||
|
if self.smart and self._filters is None:
|
||||||
|
self._filters = self._parseFilters(self.content)
|
||||||
|
return self._filters
|
||||||
|
|
||||||
def section(self):
|
def section(self):
|
||||||
""" Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to.
|
""" Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to.
|
||||||
|
|
||||||
|
@ -160,13 +177,6 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
|
||||||
""" Alias to :func:`~plexapi.playlist.Playlist.item`. """
|
""" Alias to :func:`~plexapi.playlist.Playlist.item`. """
|
||||||
return self.item(title)
|
return self.item(title)
|
||||||
|
|
||||||
def _getPlaylistItemID(self, item):
|
|
||||||
""" Match an item to a playlist item and return the item playlistItemID. """
|
|
||||||
for _item in self.items():
|
|
||||||
if _item.ratingKey == item.ratingKey:
|
|
||||||
return _item.playlistItemID
|
|
||||||
raise NotFound('Item with title "%s" not found in the playlist' % item.title)
|
|
||||||
|
|
||||||
def addItems(self, items):
|
def addItems(self, items):
|
||||||
""" Add items to the playlist.
|
""" Add items to the playlist.
|
||||||
|
|
||||||
|
@ -225,7 +235,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
|
||||||
self._server.query(key, method=self._server._session.delete)
|
self._server.query(key, method=self._server._session.delete)
|
||||||
|
|
||||||
def moveItem(self, item, after=None):
|
def moveItem(self, item, after=None):
|
||||||
""" Move an item to a new position in playlist.
|
""" Move an item to a new position in the playlist.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||||
|
|
|
@ -178,19 +178,19 @@ class MediaSettings(object):
|
||||||
photoQuality (int): photo quality on scale 0 to 100.
|
photoQuality (int): photo quality on scale 0 to 100.
|
||||||
photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`).
|
photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`).
|
||||||
videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`, may be empty).
|
videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`, may be empty).
|
||||||
subtitleSize (int|str): unknown, usually equals to 0, may be empty string.
|
subtitleSize (int): subtitle size on scale 0 to 100.
|
||||||
videoQuality (int): video quality on scale 0 to 100.
|
videoQuality (int): video quality on scale 0 to 100.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, maxVideoBitrate=4000, videoQuality=100, videoResolution='1280x720', audioBoost=100,
|
def __init__(self, maxVideoBitrate=4000, videoQuality=100, videoResolution='1280x720', audioBoost=100,
|
||||||
musicBitrate=192, photoQuality=74, photoResolution='1920x1080', subtitleSize=''):
|
musicBitrate=192, photoQuality=74, photoResolution='1920x1080', subtitleSize=100):
|
||||||
self.audioBoost = plexapi.utils.cast(int, audioBoost)
|
self.audioBoost = plexapi.utils.cast(int, audioBoost)
|
||||||
self.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) if maxVideoBitrate != '' else ''
|
self.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) if maxVideoBitrate != '' else ''
|
||||||
self.musicBitrate = plexapi.utils.cast(int, musicBitrate) if musicBitrate != '' else ''
|
self.musicBitrate = plexapi.utils.cast(int, musicBitrate) if musicBitrate != '' else ''
|
||||||
self.photoQuality = plexapi.utils.cast(int, photoQuality) if photoQuality != '' else ''
|
self.photoQuality = plexapi.utils.cast(int, photoQuality) if photoQuality != '' else ''
|
||||||
self.photoResolution = photoResolution
|
self.photoResolution = photoResolution
|
||||||
self.videoResolution = videoResolution
|
self.videoResolution = videoResolution
|
||||||
self.subtitleSize = subtitleSize
|
self.subtitleSize = plexapi.utils.cast(int, subtitleSize) if subtitleSize != '' else ''
|
||||||
self.videoQuality = plexapi.utils.cast(int, videoQuality) if videoQuality != '' else ''
|
self.videoQuality = plexapi.utils.cast(int, videoQuality) if videoQuality != '' else ''
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
@ -148,6 +148,7 @@ def searchType(libtype):
|
||||||
Parameters:
|
Parameters:
|
||||||
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track,
|
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track,
|
||||||
collection)
|
collection)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
||||||
"""
|
"""
|
||||||
|
@ -159,6 +160,24 @@ def searchType(libtype):
|
||||||
raise NotFound('Unknown libtype: %s' % libtype)
|
raise NotFound('Unknown libtype: %s' % libtype)
|
||||||
|
|
||||||
|
|
||||||
|
def reverseSearchType(libtype):
|
||||||
|
""" Returns the string value of the library type.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
libtype (int): Integer value of the library type.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
||||||
|
"""
|
||||||
|
if libtype in SEARCHTYPES:
|
||||||
|
return libtype
|
||||||
|
libtype = int(libtype)
|
||||||
|
for k, v in SEARCHTYPES.items():
|
||||||
|
if libtype == v:
|
||||||
|
return k
|
||||||
|
raise NotFound('Unknown libtype: %s' % libtype)
|
||||||
|
|
||||||
|
|
||||||
def threaded(callback, listargs):
|
def threaded(callback, listargs):
|
||||||
""" Returns the result of <callback> for each set of `*args` in listargs. Each call
|
""" Returns the result of <callback> for each set of `*args` in listargs. Each call
|
||||||
to <callback> is called concurrently in their own separate threads.
|
to <callback> is called concurrently in their own separate threads.
|
||||||
|
|
|
@ -767,7 +767,9 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
|
||||||
parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||||
parentTitle (str): Name of the season for the episode.
|
parentTitle (str): Name of the season for the episode.
|
||||||
parentYear (int): Year the season was released.
|
parentYear (int): Year the season was released.
|
||||||
|
producers (List<:class:`~plexapi.media.Producer`>): List of producers objects.
|
||||||
rating (float): Episode rating (7.9; 9.8; 8.1).
|
rating (float): Episode rating (7.9; 9.8; 8.1).
|
||||||
|
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||||
skipParent (bool): True if the show's seasons are set to hidden.
|
skipParent (bool): True if the show's seasons are set to hidden.
|
||||||
viewOffset (int): View offset in milliseconds.
|
viewOffset (int): View offset in milliseconds.
|
||||||
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
||||||
|
@ -809,7 +811,9 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
|
||||||
self.parentThumb = data.attrib.get('parentThumb')
|
self.parentThumb = data.attrib.get('parentThumb')
|
||||||
self.parentTitle = data.attrib.get('parentTitle')
|
self.parentTitle = data.attrib.get('parentTitle')
|
||||||
self.parentYear = utils.cast(int, data.attrib.get('parentYear'))
|
self.parentYear = utils.cast(int, data.attrib.get('parentYear'))
|
||||||
|
self.producers = self.findItems(data, media.Producer)
|
||||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||||
|
self.roles = self.findItems(data, media.Role)
|
||||||
self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0'))
|
self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0'))
|
||||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.writers = self.findItems(data, media.Writer)
|
self.writers = self.findItems(data, media.Writer)
|
||||||
|
@ -838,6 +842,11 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
|
||||||
""" Returns a human friendly filename. """
|
""" Returns a human friendly filename. """
|
||||||
return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode)
|
return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actors(self):
|
||||||
|
""" Alias to self.roles. """
|
||||||
|
return self.roles
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def locations(self):
|
def locations(self):
|
||||||
""" This does not exist in plex xml response but is added to have a common
|
""" This does not exist in plex xml response but is added to have a common
|
||||||
|
@ -865,6 +874,11 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
|
||||||
""" Returns the s00e00 string containing the season and episode numbers. """
|
""" Returns the s00e00 string containing the season and episode numbers. """
|
||||||
return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.episodeNumber).zfill(2))
|
return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.episodeNumber).zfill(2))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hasCommercialMarker(self):
|
||||||
|
""" Returns True if the episode has a commercial marker in the xml. """
|
||||||
|
return any(marker.type == 'commercial' for marker in self.markers)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hasIntroMarker(self):
|
def hasIntroMarker(self):
|
||||||
""" Returns True if the episode has an intro marker in the xml. """
|
""" Returns True if the episode has an intro marker in the xml. """
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue