mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-07 21:51:14 -07:00
Merge branch 'nightly' into dependabot/github_actions/nightly/actions/stale-4
This commit is contained in:
commit
ebb82eaecc
23 changed files with 451 additions and 161 deletions
103
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
Normal file
103
.github/ISSUE_TEMPLATE/BUG-REPORT.yml
vendored
Normal 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.
|
31
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
vendored
Normal file
31
.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml
vendored
Normal 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.
|
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
49
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -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.
|
||||
-->
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
|
@ -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.
|
4
.github/workflows/issues-stale.yml
vendored
4
.github/workflows/issues-stale.yml
vendored
|
@ -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
|
||||
|
|
2
.github/workflows/publish-docker.yml
vendored
2
.github/workflows/publish-docker.yml
vendored
|
@ -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
|
||||
|
|
6
.github/workflows/publish-installers.yml
vendored
6
.github/workflows/publish-installers.yml
vendored
|
@ -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
|
||||
|
|
2
.github/workflows/publish-snap.yml
vendored
2
.github/workflows/publish-snap.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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/<id_name></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">
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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. """
|
||||
|
|
|
@ -2,4 +2,4 @@ apscheduler==3.6.3
|
|||
pyinstaller==4.2
|
||||
pyopenssl==20.0.1
|
||||
pycryptodomex==3.9.9
|
||||
pywin32==300
|
||||
pywin32==301
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue