diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml
new file mode 100644
index 00000000..34f617df
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml
@@ -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.
diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
new file mode 100644
index 00000000..70e2ab33
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
@@ -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.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
deleted file mode 100644
index 0ce4d436..00000000
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ /dev/null
@@ -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: ''
-
----
-
-
-
-**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_.
-
-
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
deleted file mode 100644
index 747aefd6..00000000
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ /dev/null
@@ -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.
diff --git a/.github/workflows/issues-stale.yml b/.github/workflows/issues-stale.yml
index 5f4bc05e..85c2cfef 100644
--- a/.github/workflows/issues-stale.yml
+++ b/.github/workflows/issues-stale.yml
@@ -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
diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml
index 876c46b9..c5488c92 100644
--- a/.github/workflows/publish-docker.yml
+++ b/.github/workflows/publish-docker.yml
@@ -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
diff --git a/.github/workflows/publish-installers.yml b/.github/workflows/publish-installers.yml
index 90c845e2..da2ad05b 100644
--- a/.github/workflows/publish-installers.yml
+++ b/.github/workflows/publish-installers.yml
@@ -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
diff --git a/.github/workflows/publish-snap.yml b/.github/workflows/publish-snap.yml
index ba43e153..83ce5b97 100644
--- a/.github/workflows/publish-snap.yml
+++ b/.github/workflows/publish-snap.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7bb78a5a..cedc939f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/data/interfaces/default/newsletter_config.html b/data/interfaces/default/newsletter_config.html
index 9ee1e96d..458b0fd8 100644
--- a/data/interfaces/default/newsletter_config.html
+++ b/data/interfaces/default/newsletter_config.html
@@ -176,7 +176,7 @@
Optional: Enter a unique ID name to create a static URL to the last sent scheduled newsletter at ${http_root}newsletter/id/<id_name>.
Only letters (a-z), numbers (0-9), underscores (_) and hyphens (-) are allowed. Leave blank to disable.
- Note: Test newsletters are not considered as scheduled newsletters.
+ Note: Test newsletters are not considered scheduled newsletters.
diff --git a/lib/hashing_passwords.py b/lib/hashing_passwords.py
index 93ae5e12..4540db75 100644
--- a/lib/hashing_passwords.py
+++ b/lib/hashing_passwords.py
@@ -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)
diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py
index 0eb20924..1d0e1260 100644
--- a/lib/plexapi/collection.py
+++ b/lib/plexapi/collection.py
@@ -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.
diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py
index 006fda70..2b60144e 100644
--- a/lib/plexapi/library.py
+++ b/lib/plexapi/library.py
@@ -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'),
diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py
index 3ca69978..addc3fdf 100644
--- a/lib/plexapi/media.py
+++ b/lib/plexapi/media.py
@@ -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'))
diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py
index 2ff12a20..b5e7e649 100644
--- a/lib/plexapi/mixins.py
+++ b/lib/plexapi/mixins.py
@@ -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}
diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py
index fe856f34..15da2115 100644
--- a/lib/plexapi/myplex.py
+++ b/lib/plexapi/myplex.py
@@ -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
diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py
index c99626d0..4bcd72c5 100644
--- a/lib/plexapi/playlist.py
+++ b/lib/plexapi/playlist.py
@@ -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`,
diff --git a/lib/plexapi/sync.py b/lib/plexapi/sync.py
index ce60ca9f..53dc0636 100644
--- a/lib/plexapi/sync.py
+++ b/lib/plexapi/sync.py
@@ -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
diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py
index 5fe31caa..310200f6 100644
--- a/lib/plexapi/utils.py
+++ b/lib/plexapi/utils.py
@@ -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
for each set of `*args` in listargs. Each call
to is called concurrently in their own separate threads.
diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py
index 609eaffc..090d9502 100644
--- a/lib/plexapi/video.py
+++ b/lib/plexapi/video.py
@@ -767,7 +767,9 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
parentThumb (str): URL to season thumbnail image (/library/metadata//thumb/).
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. """
diff --git a/package/requirements-windows.txt b/package/requirements-windows.txt
index 858b0595..9719ad32 100644
--- a/package/requirements-windows.txt
+++ b/package/requirements-windows.txt
@@ -2,4 +2,4 @@ apscheduler==3.6.3
pyinstaller==4.2
pyopenssl==20.0.1
pycryptodomex==3.9.9
-pywin32==300
+pywin32==301
diff --git a/plexpy/exporter.py b/plexpy/exporter.py
index 674296a5..ab153ed8 100644
--- a/plexpy/exporter.py
+++ b/plexpy/exporter.py
@@ -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)
diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py
index 05e541ba..0f2155ad 100644
--- a/plexpy/notification_handler.py
+++ b/plexpy/notification_handler.py
@@ -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: