Merge branch 'nightly' into dependabot/github_actions/nightly/actions/stale-4

This commit is contained in:
JonnyWong16 2021-08-15 16:54:27 -07:00 committed by GitHub
commit ebb82eaecc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 451 additions and 161 deletions

103
.github/ISSUE_TEMPLATE/BUG-REPORT.yml vendored Normal file
View file

@ -0,0 +1,103 @@
name: Bug Report
description: Please do not use bug reports for support issues.
labels: ['status:awaiting-triage', 'type:bug']
body:
- type: markdown
attributes:
value: |
**THIS IS NOT THE PLACE TO ASK FOR SUPPORT!** Please use [Discord](https://tautulli.com/discord) for support issues.
- type: textarea
id: description
attributes:
label: Describe the Bug
description: A clear and concise description of the bug.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to Reproduce
description: List each action required in order to reproduce the issue.
placeholder: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: A clear and concise description of what you expected to happen.
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: Provide screenshots to help explain your problem.
- type: textarea
id: relevant
attributes:
label: Relevant Settings
description: Include all settings/configuration that are relevant to your issue. For example, Plex Media Server, newsletter, or notification settings.
placeholder: |
- eg. Plex Media Server IP address/port/checkboxes/proxy/etc.
- eg. Notification agent configuration/triggers/conditions/text/delay/grouping/etc.
- eg. Newsletter agent configuration/checkboxes/template/etc.
- Other settings
- type: input
id: version
attributes:
label: Tautulli Version
description: Check Tautulli Settings > Help & Info page.
placeholder: eg. v2.7.5
validations:
required: true
- type: input
id: branch
attributes:
label: Git Branch
description: Check Tautulli Settings > Help & Info page.
placeholder: eg. master
validations:
required: true
- type: input
id: hash
attributes:
label: Git Commit Hash
description: Check Tautulli Settings > Help & Info page.
placeholder: eg. 2cc5bf812fe05e0666aeaeb37ed550c59816fb4c
validations:
required: true
- type: input
id: platform
attributes:
label: Platform and Version
description: Check Tautulli Settings > Help & Info page.
placeholder: eg. Windows 10
validations:
required: true
- type: input
id: python
attributes:
label: Python Version
description: Check Tautulli Settings > Help & Info page.
placeholder: eg. 3.8.10
validations:
required: true
- type: input
id: browser
attributes:
label: Browser and Version
placeholder: eg. Chrome 88
validations:
required: true
- type: input
id: logs
attributes:
label: Link to Logs
description: Include a link to your **FULL** logs (not just a few lines) on [Gist](http://gist.github.com).
validations:
required: true
- type: markdown
attributes:
value: |
Make sure to close your issue when it's solved! If you found the solution yourself please comment so that others benefit from it.

View file

@ -0,0 +1,31 @@
name: Feature Request
description: Suggest a new feature for Tautulli.
labels: ['status:awaiting-triage', 'type:enhancement']
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to help improve Tautulli!
- type: textarea
id: problem
attributes:
label: Is your feature request related to a problem?
description: If so, please provide clear and concise description of the problem.
placeholder: eg. I'm always frustrated when '...'
- type: textarea
id: feature
attributes:
label: What is your feature request?
description: A clear and concise description of the feature.
validations:
required: true
- type: textarea
id: workaround
attributes:
label: Are there any workarounds?
description: A clear and concise description of any alternative solutions or features you've considered.
- type: textarea
id: additional
attributes:
label: Additional Context
description: Add any other context or screenshots about the feature request here.

View file

@ -1,49 +0,0 @@
---
name: Bug Report
about: Please do not use bug reports for support issues.
title: ''
labels: 'status:awaiting-triage, type:bug'
assignees: ''
---
<!---
THIS IS NOT THE PLACE TO ASK FOR SUPPORT! Please use [Discord](https://tautulli.com/discord) for support issues.
DO NOT ERASE THE TEMPLATE! Please complete the entire template.
--->
**Describe the Bug**
A clear and concise description of what the bug is.
**Steps to Reproduce**
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
4. See error
**Expected Behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
Provide screenshots to help explain your problem.
**Relevant Settings**
- eg. Plex Media Server IP address/port/checkboxes/proxy/etc.
- eg. Notification agent configuration/triggers/conditions/text/delay/grouping/etc.
- eg. Newsletter agent configuration/checkboxes/template/etc.
- Other settings
**Tautulli and System Info (see Tautulli settings page)**
- Version: [eg. v2.6.6]
- Git Branch: [eg. master]
- Git Commit Hash: [eg. 2cc5bf812fe05e0666aeaeb37ed550c59816fb4c]
- Platform and Version: [eg. Windows 10]
- Python Version: [e.g. 3.8.8]
- Browser and Version: [e.g. Chrome 88]
**Link to logs (required)**
Include a link to your **FULL** logs (not just a few lines) on [Gist](http://gist.github.com). _Do not upload attachments_.
<!--
Close your issue when it's solved! If you found the solution yourself please comment so that others benefit from it.
-->

View file

@ -1,20 +0,0 @@
---
name: Feature Request
about: Suggest a new feature for Tautulli.
title: ''
labels: 'status:awaiting-triage, type:enhancement'
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View file

@ -25,7 +25,7 @@ jobs:
close-pr-message: >
This PR was closed because it has been stalled for 5 days with no activity.
stale-pr-label: 'stale'
exempt-pr-labels: 'status:in-progress'
exempt-pr-labels: 'status:in-progress,dependencies'
days-before-stale: 30
days-before-close: 5
@ -42,7 +42,7 @@ jobs:
close-pr-message: >
This PR was closed because the the template was not completed after 5 days.
stale-pr-label: 'invalid:template-incomplete'
exempt-pr-labels: 'status:in-progress'
exempt-pr-labels: 'status:in-progress,dependencies'
only-labels: 'invalid:template-incomplete'
days-before-stale: 0
days-before-close: 5

View file

@ -94,7 +94,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v1
uses: technote-space/workflow-conclusion-action@v2.1.7
- name: Combine Job Status
id: status

View file

@ -72,7 +72,7 @@ jobs:
pyinstaller -y ./package/Tautulli-${{ matrix.os }}.spec
- name: Create Windows Installer
uses: joncloud/makensis-action@v3.4
uses: joncloud/makensis-action@v3.6
if: matrix.os == 'windows'
with:
script-file: ./package/Tautulli.nsi
@ -104,7 +104,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v1
uses: technote-space/workflow-conclusion-action@v2.1.7
- name: Checkout Code
uses: actions/checkout@v2
@ -168,7 +168,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v1
uses: technote-space/workflow-conclusion-action@v2.1.7
- name: Combine Job Status
id: status

View file

@ -73,7 +73,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Get Build Job Status
uses: technote-space/workflow-conclusion-action@v1
uses: technote-space/workflow-conclusion-action@v2.1.7
- name: Combine Job Status
id: status

View file

@ -1,6 +1,6 @@
# Changelog
# v2.7.5 (2021-07-15)
## v2.7.5 (2021-07-15)
* History:
* Fix: Guest users were unable to view history.
@ -17,7 +17,7 @@
* Remove: Basic Authentication setting.
# v2.7.4 (2021-06-19)
## v2.7.4 (2021-06-19)
* Activity:
* Fix: Incorrect quality profile shown on the activity card.

View file

@ -176,7 +176,7 @@
<p class="help-block">
Optional: Enter a unique ID name to create a static URL to the <em>last sent scheduled newsletter</em> at <span class="inline-pre">${http_root}newsletter/id/&lt;id_name&gt;</span>.
Only letters (a-z), numbers (0-9), underscores (_) and hyphens (-) are allowed. Leave blank to disable.<br>
Note: Test newsletters are not considered as scheduled newsletters.
Note: Test newsletters are not considered scheduled newsletters.
</p>
</div>
<div class="form-group">

View file

@ -20,6 +20,7 @@ import hashlib
from os import urandom
from base64 import b64encode, b64decode
from hashlib import pbkdf2_hmac
from hmac import compare_digest
# Parameters to PBKDF2. Only affect new passwords.
@ -53,9 +54,4 @@ def check_hash(password, hash_):
hash_a = b64decode(hash_a.encode('utf-8'))
hash_b = pbkdf2_hmac(hash_function, password, salt.encode('utf-8'), int(cost_factor), len(hash_a))
assert len(hash_a) == len(hash_b) # we requested this from pbkdf2_bin()
# Same as "return hash_a == hash_b" but takes a constant time.
# See http://carlos.bueno.org/2011/10/timing.html
diff = 0
for char_a, char_b in zip(bytearray(hash_a), bytearray(hash_b)):
diff |= char_a ^ char_b
return diff == 0
return compare_digest(hash_a, hash_b)

View file

@ -6,13 +6,13 @@ from plexapi.base import PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound, Unsupported
from plexapi.library import LibrarySection
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.utils import deprecated
@utils.registerPlexObject
class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin):
class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin, SmartFilterMixin):
""" Represents a single Collection.
Attributes:
@ -90,6 +90,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
self.userRating = utils.cast(float, data.attrib.get('userRating'))
self._items = None # cache for self.items
self._section = None # cache for self.section
self._filters = None # cache for self.filters
def __len__(self): # pragma: no cover
return len(self.items())
@ -141,6 +142,15 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
def children(self):
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):
""" 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)
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):
""" Update the filters for a smart collection.

View file

@ -847,12 +847,6 @@ class LibrarySection(PlexObject):
values = [values]
fieldType = self.getFieldType(filterField.type)
choiceTypes = {'tag', 'subtitleLanguage', 'audioLanguage', 'resolution'}
if fieldType.type in choiceTypes:
filterChoices = self.listFilterChoices(filterField.key, libtype)
else:
filterChoices = []
results = []
try:
@ -865,11 +859,8 @@ class LibrarySection(PlexObject):
value = float(value) if '.' in str(value) else int(value)
elif fieldType.type == 'string':
value = str(value)
elif fieldType.type in choiceTypes:
value = str((value.id or value.tag) if isinstance(value, media.MediaTag) else value)
matchValue = value.lower()
value = next((f.key for f in filterChoices
if matchValue in {f.key.lower(), f.title.lower()}), value)
elif fieldType.type in {'tag', 'subtitleLanguage', 'audioLanguage', 'resolution'}:
value = self._validateFieldValueTag(value, filterField, libtype)
results.append(str(value))
except (ValueError, AttributeError):
raise BadRequest('Invalid value "%s" for filter field "%s", value should be type %s'
@ -888,24 +879,47 @@ class LibrarySection(PlexObject):
else:
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):
""" 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.
"""
if isinstance(sort, str):
sort = sort.split(',')
if not isinstance(sort, (list, tuple)):
sort = [sort]
validatedSorts = []
for _sort in sort:
validatedSorts.append(self._validateSortField(_sort.strip(), libtype))
validatedSorts.append(self._validateSortField(_sort, libtype))
return ','.join(validatedSorts)
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.
"""
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:
raise BadRequest('Invalid filter sort: %s' % sort)
_libtype, sortField, sortDir = match.groups()
@ -921,16 +935,13 @@ class LibrarySection(PlexObject):
sortField = libtype + '.' + filterSort.key
if not sortDir:
sortDir = filterSort.defaultDirection
availableDirections = ['asc', 'desc', 'nullsLast']
availableDirections = ['', 'asc', 'desc', 'nullsLast']
if sortDir not in availableDirections:
raise NotFound('Unknown sort direction "%s". '
'Available sort directions: %s'
% (sortDir, availableDirections))
return '%s:%s' % (sortField, sortDir)
return '%s:%s' % (sortField, sortDir) if sortDir else sortField
def _validateAdvancedSearch(self, filters, libtype):
""" Validates an advanced search filter dictionary.
@ -1009,9 +1020,8 @@ class LibrarySection(PlexObject):
Parameters:
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
in the format ``column:dir``.
See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields.
sort (:class:`~plexapi.library.FilteringSort` or str or list, optional): A field to sort the results.
See the details below for more info.
maxresults (int, optional): Only return the specified number of results.
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
@ -1027,6 +1037,28 @@ class LibrarySection(PlexObject):
:exc:`~plexapi.exceptions.BadRequest`: When the sort or filter is invalid.
: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**
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.
* **year** (*int*): Search for a specific year.
Tag type filter values can be a :class:`~plexapi.media.MediaTag` object, the exact name
:attr:`MediaTag.tag` (*str*), or the exact id :attr:`MediaTag.id` (*int*).
Tag type filter values can be a :class:`~plexapi.library.FilterChoice` object,
: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
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'),
('id', 'asc', 'Rating Key'),
('index', 'asc', '%s Number' % self.type.capitalize()),
('random', 'asc', 'Random'),
('summary', 'asc', 'Summary'),
('tagline', 'asc', 'Tagline'),
('updatedAt', 'asc', 'Date Updated')
@ -2029,7 +2061,11 @@ class FilteringType(PlexObject):
additionalSorts.extend([
('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([
('addedAt', 'asc', 'Date Added')
])
@ -2081,10 +2117,6 @@ class FilteringType(PlexObject):
('rating', 'integer', 'Critic Rating'),
('viewOffset', 'integer', 'View Offset')
])
elif self.type == 'artist':
additionalFields.extend([
('lastViewedAt', 'date', 'Artist Last Played')
])
elif self.type == 'track':
additionalFields.extend([
('duration', 'integer', 'Duration'),

View file

@ -964,10 +964,12 @@ class Marker(PlexObject):
name = self._clean(self.firstAttr('type'))
start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start')))
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):
self._data = data
self.id = utils.cast(int, data.attrib.get('id'))
self.type = data.attrib.get('type')
self.start = utils.cast(int, data.attrib.get('startTimeOffset'))
self.end = utils.cast(int, data.attrib.get('endTimeOffset'))

View file

@ -1,5 +1,5 @@
# -*- 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.exceptions import BadRequest, NotFound
@ -559,3 +559,61 @@ class WriterMixin(object):
locked (bool): True (default) to lock the field, False to unlock the field.
"""
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}

View file

@ -623,9 +623,7 @@ class MyPlexAccount(PlexObject):
}
url = SyncList.key.format(clientId=client.clientIdentifier)
data = self.query(url, method=self._session.post, headers={
'Content-type': 'x-www-form-urlencoded',
}, params=params)
data = self.query(url, method=self._session.post, params=params)
return SyncItem(self, data, None, clientIdentifier=client.clientIdentifier)
@ -925,6 +923,10 @@ class MyPlexResource(PlexObject):
TAG = 'Device'
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):
self._data = data
self.name = data.attrib.get('name')
@ -949,10 +951,51 @@ class MyPlexResource(PlexObject):
self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0))
self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username.
def connect(self, ssl=None, timeout=None):
""" Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object.
def preferred_connections(
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.
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
assuming at least one connection was successful, the PlexServer object is built and returned.
@ -965,22 +1008,7 @@ class MyPlexResource(PlexObject):
Raises:
: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
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])
connections = self.preferred_connections(ssl, timeout, locations, schemes)
# Try connecting to all known resource connections in parellel, but
# only return the first server (in order) that provides a response.
cls = PlexServer if 'server' in self.provides else PlexClient

View file

@ -6,13 +6,13 @@ from plexapi import utils
from plexapi.base import Playable, PlexPartialObject
from plexapi.exceptions import BadRequest, NotFound, Unsupported
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.utils import deprecated
@utils.registerPlexObject
class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMixin):
""" Represents a single Playlist.
Attributes:
@ -61,6 +61,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
self._items = None # cache for self.items
self._section = None # cache for self.section
self._filters = None # cache for self.filters
def __len__(self): # pragma: no cover
return len(self.items())
@ -107,6 +108,22 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
""" Returns True if this is a photo playlist. """
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):
""" 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`. """
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):
""" Add items to the playlist.
@ -225,7 +235,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
self._server.query(key, method=self._server._session.delete)
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:
items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,

View file

@ -178,19 +178,19 @@ class MediaSettings(object):
photoQuality (int): photo quality on scale 0 to 100.
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).
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.
"""
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.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) if maxVideoBitrate != '' else ''
self.musicBitrate = plexapi.utils.cast(int, musicBitrate) if musicBitrate != '' else ''
self.photoQuality = plexapi.utils.cast(int, photoQuality) if photoQuality != '' else ''
self.photoResolution = photoResolution
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 ''
@staticmethod

View file

@ -148,6 +148,7 @@ def searchType(libtype):
Parameters:
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track,
collection)
Raises:
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
"""
@ -159,6 +160,24 @@ def searchType(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):
""" Returns the result of <callback> for each set of `*args` in listargs. Each call
to <callback> is called concurrently in their own separate threads.

View file

@ -767,7 +767,9 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
parentTitle (str): Name of the season for the episode.
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).
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
skipParent (bool): True if the show's seasons are set to hidden.
viewOffset (int): View offset in milliseconds.
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.parentTitle = data.attrib.get('parentTitle')
self.parentYear = utils.cast(int, data.attrib.get('parentYear'))
self.producers = self.findItems(data, media.Producer)
self.rating = utils.cast(float, data.attrib.get('rating'))
self.roles = self.findItems(data, media.Role)
self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0'))
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
self.writers = self.findItems(data, media.Writer)
@ -838,6 +842,11 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
""" Returns a human friendly filename. """
return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode)
@property
def actors(self):
""" Alias to self.roles. """
return self.roles
@property
def locations(self):
""" 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. """
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
def hasIntroMarker(self):
""" Returns True if the episode has an intro marker in the xml. """

View file

@ -2,4 +2,4 @@ apscheduler==3.6.3
pyinstaller==4.2
pyopenssl==20.0.1
pycryptodomex==3.9.9
pywin32==300
pywin32==301

View file

@ -566,6 +566,7 @@ class Export(object):
'guids': {
'id': None
},
'hasCommercialMarker': None,
'hasIntroMarker': None,
'hasPreviewThumbnails': None,
'index': None,
@ -732,8 +733,18 @@ class Export(object):
'parentThumb': None,
'parentTitle': None,
'parentYear': None,
'producers': {
'id': None,
'tag': None
},
'rating': None,
'ratingKey': None,
'roles': {
'id': None,
'tag': None,
'role': None,
'thumb': None
},
'seasonEpisode': None,
'seasonNumber': None,
'summary': None,
@ -1318,10 +1329,11 @@ class Export(object):
'rating', 'audienceRating', 'audienceRatingImage', 'userRating', 'contentRating',
'summary', 'guid', 'duration', 'durationHuman', 'type', 'episodeNumber', 'seasonEpisode',
'parentTitle', 'parentRatingKey', 'parentGuid', 'parentYear', 'seasonNumber',
'grandparentTitle', 'grandparentRatingKey', 'grandparentGuid', 'hasIntroMarker'
'grandparentTitle', 'grandparentRatingKey', 'grandparentGuid',
'hasCommercialMarker', 'hasIntroMarker'
],
2: [
'collections.tag', 'directors.tag', 'writers.tag',
'collections.tag', 'directors.tag', 'writers.tag', 'producers.tag', 'roles.tag', 'roles.role',
'fields.name', 'fields.locked', 'guids.id',
'markers.type', 'markers.start', 'markers.end'
],
@ -1815,7 +1827,11 @@ class Export(object):
# Only playlists export allowed for users
items = self.obj.playlists()
else:
method = getattr(self.obj, self.export_type)
if self.export_type != 'all':
export_method = self.export_type + 's'
else:
export_method = self.export_type
method = getattr(self.obj, export_method)
items = method()
self.total_items = len(items)

View file

@ -1840,6 +1840,11 @@ def str_eval(field_name, kwargs):
class CustomFormatter(Formatter):
def __init__(self, default='{{{0}}}'):
self.default = default
self.eval_regex = re.compile(r'`.*?`')
self.eval_replace = {
':': '%%colon%%',
'!': '%%exclamation%%'
}
def convert_field(self, value, conversion):
if conversion is None:
@ -1887,8 +1892,21 @@ class CustomFormatter(Formatter):
return super(CustomFormatter, self).get_value(key, args, kwargs)
def parse(self, format_string):
# Replace characters in eval expression
for match in re.findall(self.eval_regex, format_string):
replaced = match
for k, v in self.eval_replace.items():
replaced = replaced.replace(k, v)
format_string = format_string.replace(match, replaced)
parsed = super(CustomFormatter, self).parse(format_string)
for literal_text, field_name, format_spec, conversion in parsed:
# Restore characters in eval expression
if field_name:
for k, v in self.eval_replace.items():
field_name = field_name.replace(v, k)
real_format_string = ''
if field_name:
real_format_string += field_name
@ -1900,8 +1918,8 @@ class CustomFormatter(Formatter):
prefix = None
suffix = None
matches = re.findall(r'`.*?`', real_format_string)
temp_format_string = re.sub(r'`.*`', '{}', real_format_string)
matches = re.findall(self.eval_regex, real_format_string)
temp_format_string = re.sub(self.eval_regex, '{}', real_format_string)
prefix_split = temp_format_string.split('<')
if len(prefix_split) == 2: