mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-08 06:00:51 -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: >
|
close-pr-message: >
|
||||||
This PR was closed because it has been stalled for 5 days with no activity.
|
This PR was closed because it has been stalled for 5 days with no activity.
|
||||||
stale-pr-label: 'stale'
|
stale-pr-label: 'stale'
|
||||||
exempt-pr-labels: 'status:in-progress'
|
exempt-pr-labels: 'status:in-progress,dependencies'
|
||||||
days-before-stale: 30
|
days-before-stale: 30
|
||||||
days-before-close: 5
|
days-before-close: 5
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ jobs:
|
||||||
close-pr-message: >
|
close-pr-message: >
|
||||||
This PR was closed because the the template was not completed after 5 days.
|
This PR was closed because the the template was not completed after 5 days.
|
||||||
stale-pr-label: 'invalid:template-incomplete'
|
stale-pr-label: 'invalid:template-incomplete'
|
||||||
exempt-pr-labels: 'status:in-progress'
|
exempt-pr-labels: 'status:in-progress,dependencies'
|
||||||
only-labels: 'invalid:template-incomplete'
|
only-labels: 'invalid:template-incomplete'
|
||||||
days-before-stale: 0
|
days-before-stale: 0
|
||||||
days-before-close: 5
|
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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- 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
|
- name: Combine Job Status
|
||||||
id: 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
|
pyinstaller -y ./package/Tautulli-${{ matrix.os }}.spec
|
||||||
|
|
||||||
- name: Create Windows Installer
|
- name: Create Windows Installer
|
||||||
uses: joncloud/makensis-action@v3.4
|
uses: joncloud/makensis-action@v3.6
|
||||||
if: matrix.os == 'windows'
|
if: matrix.os == 'windows'
|
||||||
with:
|
with:
|
||||||
script-file: ./package/Tautulli.nsi
|
script-file: ./package/Tautulli.nsi
|
||||||
|
@ -104,7 +104,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- name: Get Build Job Status
|
||||||
uses: technote-space/workflow-conclusion-action@v1
|
uses: technote-space/workflow-conclusion-action@v2.1.7
|
||||||
|
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v2
|
||||||
|
@ -168,7 +168,7 @@ jobs:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- 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
|
- name: Combine Job Status
|
||||||
id: 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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Get Build Job Status
|
- 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
|
- name: Combine Job Status
|
||||||
id: status
|
id: status
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
# v2.7.5 (2021-07-15)
|
## v2.7.5 (2021-07-15)
|
||||||
|
|
||||||
* History:
|
* History:
|
||||||
* Fix: Guest users were unable to view history.
|
* Fix: Guest users were unable to view history.
|
||||||
|
@ -17,7 +17,7 @@
|
||||||
* Remove: Basic Authentication setting.
|
* Remove: Basic Authentication setting.
|
||||||
|
|
||||||
|
|
||||||
# v2.7.4 (2021-06-19)
|
## v2.7.4 (2021-06-19)
|
||||||
|
|
||||||
* Activity:
|
* Activity:
|
||||||
* Fix: Incorrect quality profile shown on the activity card.
|
* Fix: Incorrect quality profile shown on the activity card.
|
||||||
|
|
|
@ -176,7 +176,7 @@
|
||||||
<p class="help-block">
|
<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>.
|
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>
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
@ -20,6 +20,7 @@ import hashlib
|
||||||
from os import urandom
|
from os import urandom
|
||||||
from base64 import b64encode, b64decode
|
from base64 import b64encode, b64decode
|
||||||
from hashlib import pbkdf2_hmac
|
from hashlib import pbkdf2_hmac
|
||||||
|
from hmac import compare_digest
|
||||||
|
|
||||||
|
|
||||||
# Parameters to PBKDF2. Only affect new passwords.
|
# 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_a = b64decode(hash_a.encode('utf-8'))
|
||||||
hash_b = pbkdf2_hmac(hash_function, password, salt.encode('utf-8'), int(cost_factor), len(hash_a))
|
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()
|
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.
|
return compare_digest(hash_a, hash_b)
|
||||||
# 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
|
|
||||||
|
|
|
@ -6,13 +6,13 @@ from plexapi.base import PlexPartialObject
|
||||||
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
||||||
from plexapi.library import LibrarySection
|
from plexapi.library import LibrarySection
|
||||||
from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin
|
from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin
|
||||||
from plexapi.mixins import LabelMixin
|
from plexapi.mixins import LabelMixin, SmartFilterMixin
|
||||||
from plexapi.playqueue import PlayQueue
|
from plexapi.playqueue import PlayQueue
|
||||||
from plexapi.utils import deprecated
|
from plexapi.utils import deprecated
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin):
|
class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin, SmartFilterMixin):
|
||||||
""" Represents a single Collection.
|
""" Represents a single Collection.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
@ -90,6 +90,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
|
||||||
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
self.userRating = utils.cast(float, data.attrib.get('userRating'))
|
||||||
self._items = None # cache for self.items
|
self._items = None # cache for self.items
|
||||||
self._section = None # cache for self.section
|
self._section = None # cache for self.section
|
||||||
|
self._filters = None # cache for self.filters
|
||||||
|
|
||||||
def __len__(self): # pragma: no cover
|
def __len__(self): # pragma: no cover
|
||||||
return len(self.items())
|
return len(self.items())
|
||||||
|
@ -141,6 +142,15 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
|
||||||
def children(self):
|
def children(self):
|
||||||
return self.items()
|
return self.items()
|
||||||
|
|
||||||
|
def filters(self):
|
||||||
|
""" Returns the search filter dict for smart collection.
|
||||||
|
The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search`
|
||||||
|
to get the list of items.
|
||||||
|
"""
|
||||||
|
if self.smart and self._filters is None:
|
||||||
|
self._filters = self._parseFilters(self.content)
|
||||||
|
return self._filters
|
||||||
|
|
||||||
def section(self):
|
def section(self):
|
||||||
""" Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to.
|
""" Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to.
|
||||||
"""
|
"""
|
||||||
|
@ -277,6 +287,28 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin
|
||||||
key = '%s/items/%s' % (self.key, item.ratingKey)
|
key = '%s/items/%s' % (self.key, item.ratingKey)
|
||||||
self._server.query(key, method=self._server._session.delete)
|
self._server.query(key, method=self._server._session.delete)
|
||||||
|
|
||||||
|
def moveItem(self, item, after=None):
|
||||||
|
""" Move an item to a new position in the collection.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||||
|
or :class:`~plexapi.photo.Photo` objects to be moved in the collection.
|
||||||
|
after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||||
|
or :class:`~plexapi.photo.Photo` objects to move the item after in the collection.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
:class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart collection.
|
||||||
|
"""
|
||||||
|
if self.smart:
|
||||||
|
raise BadRequest('Cannot move items in a smart collection.')
|
||||||
|
|
||||||
|
key = '%s/items/%s/move' % (self.key, item.ratingKey)
|
||||||
|
|
||||||
|
if after:
|
||||||
|
key += '?after=%s' % after.ratingKey
|
||||||
|
|
||||||
|
self._server.query(key, method=self._server._session.put)
|
||||||
|
|
||||||
def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs):
|
def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs):
|
||||||
""" Update the filters for a smart collection.
|
""" Update the filters for a smart collection.
|
||||||
|
|
||||||
|
|
|
@ -847,12 +847,6 @@ class LibrarySection(PlexObject):
|
||||||
values = [values]
|
values = [values]
|
||||||
|
|
||||||
fieldType = self.getFieldType(filterField.type)
|
fieldType = self.getFieldType(filterField.type)
|
||||||
choiceTypes = {'tag', 'subtitleLanguage', 'audioLanguage', 'resolution'}
|
|
||||||
if fieldType.type in choiceTypes:
|
|
||||||
filterChoices = self.listFilterChoices(filterField.key, libtype)
|
|
||||||
else:
|
|
||||||
filterChoices = []
|
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
@ -865,11 +859,8 @@ class LibrarySection(PlexObject):
|
||||||
value = float(value) if '.' in str(value) else int(value)
|
value = float(value) if '.' in str(value) else int(value)
|
||||||
elif fieldType.type == 'string':
|
elif fieldType.type == 'string':
|
||||||
value = str(value)
|
value = str(value)
|
||||||
elif fieldType.type in choiceTypes:
|
elif fieldType.type in {'tag', 'subtitleLanguage', 'audioLanguage', 'resolution'}:
|
||||||
value = str((value.id or value.tag) if isinstance(value, media.MediaTag) else value)
|
value = self._validateFieldValueTag(value, filterField, libtype)
|
||||||
matchValue = value.lower()
|
|
||||||
value = next((f.key for f in filterChoices
|
|
||||||
if matchValue in {f.key.lower(), f.title.lower()}), value)
|
|
||||||
results.append(str(value))
|
results.append(str(value))
|
||||||
except (ValueError, AttributeError):
|
except (ValueError, AttributeError):
|
||||||
raise BadRequest('Invalid value "%s" for filter field "%s", value should be type %s'
|
raise BadRequest('Invalid value "%s" for filter field "%s", value should be type %s'
|
||||||
|
@ -888,24 +879,47 @@ class LibrarySection(PlexObject):
|
||||||
else:
|
else:
|
||||||
return int(utils.toDatetime(value, '%Y-%m-%d').timestamp())
|
return int(utils.toDatetime(value, '%Y-%m-%d').timestamp())
|
||||||
|
|
||||||
|
def _validateFieldValueTag(self, value, filterField, libtype):
|
||||||
|
""" Validates a filter tag value. A filter tag value can be a :class:`~plexapi.library.FilterChoice` object,
|
||||||
|
a :class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*),
|
||||||
|
or the exact id :attr:`MediaTag.id` (*int*).
|
||||||
|
"""
|
||||||
|
if isinstance(value, FilterChoice):
|
||||||
|
return value.key
|
||||||
|
if isinstance(value, media.MediaTag):
|
||||||
|
value = str(value.id or value.tag)
|
||||||
|
else:
|
||||||
|
value = str(value)
|
||||||
|
filterChoices = self.listFilterChoices(filterField.key, libtype)
|
||||||
|
matchValue = value.lower()
|
||||||
|
return next((f.key for f in filterChoices if matchValue in {f.key.lower(), f.title.lower()}), value)
|
||||||
|
|
||||||
def _validateSortFields(self, sort, libtype=None):
|
def _validateSortFields(self, sort, libtype=None):
|
||||||
""" Validates a list of filter sort fields is available for the library.
|
""" Validates a list of filter sort fields is available for the library. Sort fields can be a
|
||||||
|
list of :class:`~plexapi.library.FilteringSort` objects, or a comma separated string.
|
||||||
Returns the validated comma separated sort fields string.
|
Returns the validated comma separated sort fields string.
|
||||||
"""
|
"""
|
||||||
if isinstance(sort, str):
|
if isinstance(sort, str):
|
||||||
sort = sort.split(',')
|
sort = sort.split(',')
|
||||||
|
|
||||||
|
if not isinstance(sort, (list, tuple)):
|
||||||
|
sort = [sort]
|
||||||
|
|
||||||
validatedSorts = []
|
validatedSorts = []
|
||||||
for _sort in sort:
|
for _sort in sort:
|
||||||
validatedSorts.append(self._validateSortField(_sort.strip(), libtype))
|
validatedSorts.append(self._validateSortField(_sort, libtype))
|
||||||
|
|
||||||
return ','.join(validatedSorts)
|
return ','.join(validatedSorts)
|
||||||
|
|
||||||
def _validateSortField(self, sort, libtype=None):
|
def _validateSortField(self, sort, libtype=None):
|
||||||
""" Validates a filter sort field is available for the library.
|
""" Validates a filter sort field is available for the library. A sort field can be a
|
||||||
|
:class:`~plexapi.library.FilteringSort` object, or a string.
|
||||||
Returns the validated sort field string.
|
Returns the validated sort field string.
|
||||||
"""
|
"""
|
||||||
match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+):?([a-zA-Z]*)', sort)
|
if isinstance(sort, FilteringSort):
|
||||||
|
return '%s.%s:%s' % (libtype or self.TYPE, sort.key, sort.defaultDirection)
|
||||||
|
|
||||||
|
match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+):?([a-zA-Z]*)', sort.strip())
|
||||||
if not match:
|
if not match:
|
||||||
raise BadRequest('Invalid filter sort: %s' % sort)
|
raise BadRequest('Invalid filter sort: %s' % sort)
|
||||||
_libtype, sortField, sortDir = match.groups()
|
_libtype, sortField, sortDir = match.groups()
|
||||||
|
@ -921,16 +935,13 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
sortField = libtype + '.' + filterSort.key
|
sortField = libtype + '.' + filterSort.key
|
||||||
|
|
||||||
if not sortDir:
|
availableDirections = ['', 'asc', 'desc', 'nullsLast']
|
||||||
sortDir = filterSort.defaultDirection
|
|
||||||
|
|
||||||
availableDirections = ['asc', 'desc', 'nullsLast']
|
|
||||||
if sortDir not in availableDirections:
|
if sortDir not in availableDirections:
|
||||||
raise NotFound('Unknown sort direction "%s". '
|
raise NotFound('Unknown sort direction "%s". '
|
||||||
'Available sort directions: %s'
|
'Available sort directions: %s'
|
||||||
% (sortDir, availableDirections))
|
% (sortDir, availableDirections))
|
||||||
|
|
||||||
return '%s:%s' % (sortField, sortDir)
|
return '%s:%s' % (sortField, sortDir) if sortDir else sortField
|
||||||
|
|
||||||
def _validateAdvancedSearch(self, filters, libtype):
|
def _validateAdvancedSearch(self, filters, libtype):
|
||||||
""" Validates an advanced search filter dictionary.
|
""" Validates an advanced search filter dictionary.
|
||||||
|
@ -1009,9 +1020,8 @@ class LibrarySection(PlexObject):
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
title (str, optional): General string query to search for. Partial string matches are allowed.
|
title (str, optional): General string query to search for. Partial string matches are allowed.
|
||||||
sort (str or list, optional): A string of comma separated sort fields or a list of sort fields
|
sort (:class:`~plexapi.library.FilteringSort` or str or list, optional): A field to sort the results.
|
||||||
in the format ``column:dir``.
|
See the details below for more info.
|
||||||
See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields.
|
|
||||||
maxresults (int, optional): Only return the specified number of results.
|
maxresults (int, optional): Only return the specified number of results.
|
||||||
libtype (str, optional): Return results of a specific type (movie, show, season, episode,
|
libtype (str, optional): Return results of a specific type (movie, show, season, episode,
|
||||||
artist, album, track, photoalbum, photo, collection) (e.g. ``libtype='episode'`` will only
|
artist, album, track, photoalbum, photo, collection) (e.g. ``libtype='episode'`` will only
|
||||||
|
@ -1027,6 +1037,28 @@ class LibrarySection(PlexObject):
|
||||||
:exc:`~plexapi.exceptions.BadRequest`: When the sort or filter is invalid.
|
:exc:`~plexapi.exceptions.BadRequest`: When the sort or filter is invalid.
|
||||||
:exc:`~plexapi.exceptions.NotFound`: When applying an unknown sort or filter.
|
:exc:`~plexapi.exceptions.NotFound`: When applying an unknown sort or filter.
|
||||||
|
|
||||||
|
**Sorting Results**
|
||||||
|
|
||||||
|
The search results can be sorted by including the ``sort`` parameter.
|
||||||
|
|
||||||
|
* See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields.
|
||||||
|
|
||||||
|
The ``sort`` parameter can be a :class:`~plexapi.library.FilteringSort` object or a sort string in the
|
||||||
|
format ``field:dir``. The sort direction ``dir`` can be ``asc``, ``desc``, or ``nullsLast``. Omitting the
|
||||||
|
sort direction or using a :class:`~plexapi.library.FilteringSort` object will sort the results in the default
|
||||||
|
direction of the field. Multi-sorting on multiple fields can be achieved by using a comma separated list of
|
||||||
|
sort strings, or a list of :class:`~plexapi.library.FilteringSort` object or strings.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
library.search(sort="titleSort:desc") # Sort title in descending order
|
||||||
|
library.search(sort="titleSort") # Sort title in the default order
|
||||||
|
# Multi-sort by year in descending order, then by audience rating in descending order
|
||||||
|
library.search(sort="year:desc,audienceRating:desc")
|
||||||
|
library.search(sort=["year:desc", "audienceRating:desc"])
|
||||||
|
|
||||||
**Using Plex Filters**
|
**Using Plex Filters**
|
||||||
|
|
||||||
Any of the available custom filters can be applied to the search results
|
Any of the available custom filters can be applied to the search results
|
||||||
|
@ -1065,8 +1097,9 @@ class LibrarySection(PlexObject):
|
||||||
* **writer** (:class:`~plexapi.media.MediaTag`): Search for the name of a writer.
|
* **writer** (:class:`~plexapi.media.MediaTag`): Search for the name of a writer.
|
||||||
* **year** (*int*): Search for a specific year.
|
* **year** (*int*): Search for a specific year.
|
||||||
|
|
||||||
Tag type filter values can be a :class:`~plexapi.media.MediaTag` object, the exact name
|
Tag type filter values can be a :class:`~plexapi.library.FilterChoice` object,
|
||||||
:attr:`MediaTag.tag` (*str*), or the exact id :attr:`MediaTag.id` (*int*).
|
:class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*),
|
||||||
|
or the exact id :attr:`MediaTag.id` (*int*).
|
||||||
|
|
||||||
Date type filter values can be a ``datetime`` object, a relative date using a one of the
|
Date type filter values can be a ``datetime`` object, a relative date using a one of the
|
||||||
available date suffixes (e.g. ``30d``) (*str*), or a date in ``YYYY-MM-DD`` (*str*) format.
|
available date suffixes (e.g. ``30d``) (*str*), or a date in ``YYYY-MM-DD`` (*str*) format.
|
||||||
|
@ -2014,7 +2047,6 @@ class FilteringType(PlexObject):
|
||||||
('guid', 'asc', 'Guid'),
|
('guid', 'asc', 'Guid'),
|
||||||
('id', 'asc', 'Rating Key'),
|
('id', 'asc', 'Rating Key'),
|
||||||
('index', 'asc', '%s Number' % self.type.capitalize()),
|
('index', 'asc', '%s Number' % self.type.capitalize()),
|
||||||
('random', 'asc', 'Random'),
|
|
||||||
('summary', 'asc', 'Summary'),
|
('summary', 'asc', 'Summary'),
|
||||||
('tagline', 'asc', 'Tagline'),
|
('tagline', 'asc', 'Tagline'),
|
||||||
('updatedAt', 'asc', 'Date Updated')
|
('updatedAt', 'asc', 'Date Updated')
|
||||||
|
@ -2029,7 +2061,11 @@ class FilteringType(PlexObject):
|
||||||
additionalSorts.extend([
|
additionalSorts.extend([
|
||||||
('absoluteIndex', 'asc', 'Absolute Index')
|
('absoluteIndex', 'asc', 'Absolute Index')
|
||||||
])
|
])
|
||||||
if self.type == 'collection':
|
elif self.type == 'photo':
|
||||||
|
additionalSorts.extend([
|
||||||
|
('viewUpdatedAt', 'desc', 'View Updated At')
|
||||||
|
])
|
||||||
|
elif self.type == 'collection':
|
||||||
additionalSorts.extend([
|
additionalSorts.extend([
|
||||||
('addedAt', 'asc', 'Date Added')
|
('addedAt', 'asc', 'Date Added')
|
||||||
])
|
])
|
||||||
|
@ -2081,10 +2117,6 @@ class FilteringType(PlexObject):
|
||||||
('rating', 'integer', 'Critic Rating'),
|
('rating', 'integer', 'Critic Rating'),
|
||||||
('viewOffset', 'integer', 'View Offset')
|
('viewOffset', 'integer', 'View Offset')
|
||||||
])
|
])
|
||||||
elif self.type == 'artist':
|
|
||||||
additionalFields.extend([
|
|
||||||
('lastViewedAt', 'date', 'Artist Last Played')
|
|
||||||
])
|
|
||||||
elif self.type == 'track':
|
elif self.type == 'track':
|
||||||
additionalFields.extend([
|
additionalFields.extend([
|
||||||
('duration', 'integer', 'Duration'),
|
('duration', 'integer', 'Duration'),
|
||||||
|
|
|
@ -964,10 +964,12 @@ class Marker(PlexObject):
|
||||||
name = self._clean(self.firstAttr('type'))
|
name = self._clean(self.firstAttr('type'))
|
||||||
start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start')))
|
start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start')))
|
||||||
end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end')))
|
end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end')))
|
||||||
return '<%s:%s %s - %s>' % (self.__class__.__name__, name, start, end)
|
offsets = '%s-%s' % (start, end)
|
||||||
|
return '<%s>' % ':'.join([self.__class__.__name__, name, offsets])
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
self._data = data
|
self._data = data
|
||||||
|
self.id = utils.cast(int, data.attrib.get('id'))
|
||||||
self.type = data.attrib.get('type')
|
self.type = data.attrib.get('type')
|
||||||
self.start = utils.cast(int, data.attrib.get('startTimeOffset'))
|
self.start = utils.cast(int, data.attrib.get('startTimeOffset'))
|
||||||
self.end = utils.cast(int, data.attrib.get('endTimeOffset'))
|
self.end = utils.cast(int, data.attrib.get('endTimeOffset'))
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from urllib.parse import quote_plus, urlencode
|
from urllib.parse import parse_qsl, quote_plus, unquote, urlencode, urlsplit
|
||||||
|
|
||||||
from plexapi import media, settings, utils
|
from plexapi import media, settings, utils
|
||||||
from plexapi.exceptions import BadRequest, NotFound
|
from plexapi.exceptions import BadRequest, NotFound
|
||||||
|
@ -559,3 +559,61 @@ class WriterMixin(object):
|
||||||
locked (bool): True (default) to lock the field, False to unlock the field.
|
locked (bool): True (default) to lock the field, False to unlock the field.
|
||||||
"""
|
"""
|
||||||
self._edit_tags('writer', writers, locked=locked, remove=True)
|
self._edit_tags('writer', writers, locked=locked, remove=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SmartFilterMixin(object):
|
||||||
|
""" Mixing for Plex objects that can have smart filters. """
|
||||||
|
|
||||||
|
def _parseFilters(self, content):
|
||||||
|
""" Parse the content string and returns the filter dict. """
|
||||||
|
content = urlsplit(unquote(content))
|
||||||
|
filters = {}
|
||||||
|
filterOp = 'and'
|
||||||
|
filterGroups = [[]]
|
||||||
|
|
||||||
|
for key, value in parse_qsl(content.query):
|
||||||
|
# Move = sign to key when operator is ==
|
||||||
|
if value.startswith('='):
|
||||||
|
key += '='
|
||||||
|
value = value[1:]
|
||||||
|
|
||||||
|
if key == 'type':
|
||||||
|
filters['libtype'] = utils.reverseSearchType(value)
|
||||||
|
elif key == 'sort':
|
||||||
|
filters['sort'] = value.split(',')
|
||||||
|
elif key == 'limit':
|
||||||
|
filters['limit'] = int(value)
|
||||||
|
elif key == 'push':
|
||||||
|
filterGroups[-1].append([])
|
||||||
|
filterGroups.append(filterGroups[-1][-1])
|
||||||
|
elif key == 'and':
|
||||||
|
filterOp = 'and'
|
||||||
|
elif key == 'or':
|
||||||
|
filterOp = 'or'
|
||||||
|
elif key == 'pop':
|
||||||
|
filterGroups[-1].insert(0, filterOp)
|
||||||
|
filterGroups.pop()
|
||||||
|
else:
|
||||||
|
filterGroups[-1].append({key: value})
|
||||||
|
|
||||||
|
if filterGroups:
|
||||||
|
filters['filters'] = self._formatFilterGroups(filterGroups.pop())
|
||||||
|
return filters
|
||||||
|
|
||||||
|
def _formatFilterGroups(self, groups):
|
||||||
|
""" Formats the filter groups into the advanced search rules. """
|
||||||
|
if len(groups) == 1 and isinstance(groups[0], list):
|
||||||
|
groups = groups.pop()
|
||||||
|
|
||||||
|
filterOp = 'and'
|
||||||
|
rules = []
|
||||||
|
|
||||||
|
for g in groups:
|
||||||
|
if isinstance(g, list):
|
||||||
|
rules.append(self._formatFilterGroups(g))
|
||||||
|
elif isinstance(g, dict):
|
||||||
|
rules.append(g)
|
||||||
|
elif g in {'and', 'or'}:
|
||||||
|
filterOp = g
|
||||||
|
|
||||||
|
return {filterOp: rules}
|
||||||
|
|
|
@ -623,9 +623,7 @@ class MyPlexAccount(PlexObject):
|
||||||
}
|
}
|
||||||
|
|
||||||
url = SyncList.key.format(clientId=client.clientIdentifier)
|
url = SyncList.key.format(clientId=client.clientIdentifier)
|
||||||
data = self.query(url, method=self._session.post, headers={
|
data = self.query(url, method=self._session.post, params=params)
|
||||||
'Content-type': 'x-www-form-urlencoded',
|
|
||||||
}, params=params)
|
|
||||||
|
|
||||||
return SyncItem(self, data, None, clientIdentifier=client.clientIdentifier)
|
return SyncItem(self, data, None, clientIdentifier=client.clientIdentifier)
|
||||||
|
|
||||||
|
@ -925,6 +923,10 @@ class MyPlexResource(PlexObject):
|
||||||
TAG = 'Device'
|
TAG = 'Device'
|
||||||
key = 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
|
key = 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1'
|
||||||
|
|
||||||
|
# Default order to prioritize available resource connections
|
||||||
|
DEFAULT_LOCATION_ORDER = ['local', 'remote', 'relay']
|
||||||
|
DEFAULT_SCHEME_ORDER = ['https', 'http']
|
||||||
|
|
||||||
def _loadData(self, data):
|
def _loadData(self, data):
|
||||||
self._data = data
|
self._data = data
|
||||||
self.name = data.attrib.get('name')
|
self.name = data.attrib.get('name')
|
||||||
|
@ -949,10 +951,51 @@ class MyPlexResource(PlexObject):
|
||||||
self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0))
|
self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0))
|
||||||
self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username.
|
self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username.
|
||||||
|
|
||||||
def connect(self, ssl=None, timeout=None):
|
def preferred_connections(
|
||||||
""" Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object.
|
self,
|
||||||
|
ssl=None,
|
||||||
|
timeout=None,
|
||||||
|
locations=DEFAULT_LOCATION_ORDER,
|
||||||
|
schemes=DEFAULT_SCHEME_ORDER,
|
||||||
|
):
|
||||||
|
""" Returns a sorted list of the available connection addresses for this resource.
|
||||||
Often times there is more than one address specified for a server or client.
|
Often times there is more than one address specified for a server or client.
|
||||||
This function will prioritize local connections before remote or relay and HTTPS before HTTP.
|
Default behavior will prioritize local connections before remote or relay and HTTPS before HTTP.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
ssl (bool, optional): Set True to only connect to HTTPS connections. Set False to
|
||||||
|
only connect to HTTP connections. Set None (default) to connect to any
|
||||||
|
HTTP or HTTPS connection.
|
||||||
|
timeout (int, optional): The timeout in seconds to attempt each connection.
|
||||||
|
"""
|
||||||
|
connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations}
|
||||||
|
for connection in self.connections:
|
||||||
|
# Only check non-local connections unless we own the resource
|
||||||
|
if self.owned or (not self.owned and not connection.local):
|
||||||
|
location = 'relay' if connection.relay else ('local' if connection.local else 'remote')
|
||||||
|
if location not in locations:
|
||||||
|
continue
|
||||||
|
if 'http' in schemes:
|
||||||
|
connections_dict[location]['http'].append(connection.httpuri)
|
||||||
|
if 'https' in schemes:
|
||||||
|
connections_dict[location]['https'].append(connection.uri)
|
||||||
|
if ssl is True: schemes.remove('http')
|
||||||
|
elif ssl is False: schemes.remove('https')
|
||||||
|
connections = []
|
||||||
|
for location in locations:
|
||||||
|
for scheme in schemes:
|
||||||
|
connections.extend(connections_dict[location][scheme])
|
||||||
|
return connections
|
||||||
|
|
||||||
|
def connect(
|
||||||
|
self,
|
||||||
|
ssl=None,
|
||||||
|
timeout=None,
|
||||||
|
locations=DEFAULT_LOCATION_ORDER,
|
||||||
|
schemes=DEFAULT_SCHEME_ORDER,
|
||||||
|
):
|
||||||
|
""" Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object.
|
||||||
|
Uses `MyPlexResource.preferred_connections()` to generate the priority order of connection addresses.
|
||||||
After trying to connect to all available addresses for this resource and
|
After trying to connect to all available addresses for this resource and
|
||||||
assuming at least one connection was successful, the PlexServer object is built and returned.
|
assuming at least one connection was successful, the PlexServer object is built and returned.
|
||||||
|
|
||||||
|
@ -965,22 +1008,7 @@ class MyPlexResource(PlexObject):
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
:exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource.
|
||||||
"""
|
"""
|
||||||
# Keys in the order we want the connections to be sorted
|
connections = self.preferred_connections(ssl, timeout, locations, schemes)
|
||||||
locations = ['local', 'remote', 'relay']
|
|
||||||
schemes = ['https', 'http']
|
|
||||||
connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations}
|
|
||||||
for connection in self.connections:
|
|
||||||
# Only check non-local connections unless we own the resource
|
|
||||||
if self.owned or (not self.owned and not connection.local):
|
|
||||||
location = 'relay' if connection.relay else ('local' if connection.local else 'remote')
|
|
||||||
connections_dict[location]['http'].append(connection.httpuri)
|
|
||||||
connections_dict[location]['https'].append(connection.uri)
|
|
||||||
if ssl is True: schemes.remove('http')
|
|
||||||
elif ssl is False: schemes.remove('https')
|
|
||||||
connections = []
|
|
||||||
for location in locations:
|
|
||||||
for scheme in schemes:
|
|
||||||
connections.extend(connections_dict[location][scheme])
|
|
||||||
# Try connecting to all known resource connections in parellel, but
|
# Try connecting to all known resource connections in parellel, but
|
||||||
# only return the first server (in order) that provides a response.
|
# only return the first server (in order) that provides a response.
|
||||||
cls = PlexServer if 'server' in self.provides else PlexClient
|
cls = PlexServer if 'server' in self.provides else PlexClient
|
||||||
|
|
|
@ -6,13 +6,13 @@ from plexapi import utils
|
||||||
from plexapi.base import Playable, PlexPartialObject
|
from plexapi.base import Playable, PlexPartialObject
|
||||||
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
from plexapi.exceptions import BadRequest, NotFound, Unsupported
|
||||||
from plexapi.library import LibrarySection
|
from plexapi.library import LibrarySection
|
||||||
from plexapi.mixins import ArtMixin, PosterMixin
|
from plexapi.mixins import ArtMixin, PosterMixin, SmartFilterMixin
|
||||||
from plexapi.playqueue import PlayQueue
|
from plexapi.playqueue import PlayQueue
|
||||||
from plexapi.utils import deprecated
|
from plexapi.utils import deprecated
|
||||||
|
|
||||||
|
|
||||||
@utils.registerPlexObject
|
@utils.registerPlexObject
|
||||||
class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
|
class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMixin):
|
||||||
""" Represents a single Playlist.
|
""" Represents a single Playlist.
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
|
@ -61,6 +61,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
|
||||||
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt'))
|
||||||
self._items = None # cache for self.items
|
self._items = None # cache for self.items
|
||||||
self._section = None # cache for self.section
|
self._section = None # cache for self.section
|
||||||
|
self._filters = None # cache for self.filters
|
||||||
|
|
||||||
def __len__(self): # pragma: no cover
|
def __len__(self): # pragma: no cover
|
||||||
return len(self.items())
|
return len(self.items())
|
||||||
|
@ -107,6 +108,22 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
|
||||||
""" Returns True if this is a photo playlist. """
|
""" Returns True if this is a photo playlist. """
|
||||||
return self.playlistType == 'photo'
|
return self.playlistType == 'photo'
|
||||||
|
|
||||||
|
def _getPlaylistItemID(self, item):
|
||||||
|
""" Match an item to a playlist item and return the item playlistItemID. """
|
||||||
|
for _item in self.items():
|
||||||
|
if _item.ratingKey == item.ratingKey:
|
||||||
|
return _item.playlistItemID
|
||||||
|
raise NotFound('Item with title "%s" not found in the playlist' % item.title)
|
||||||
|
|
||||||
|
def filters(self):
|
||||||
|
""" Returns the search filter dict for smart playlist.
|
||||||
|
The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search`
|
||||||
|
to get the list of items.
|
||||||
|
"""
|
||||||
|
if self.smart and self._filters is None:
|
||||||
|
self._filters = self._parseFilters(self.content)
|
||||||
|
return self._filters
|
||||||
|
|
||||||
def section(self):
|
def section(self):
|
||||||
""" Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to.
|
""" Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to.
|
||||||
|
|
||||||
|
@ -160,13 +177,6 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
|
||||||
""" Alias to :func:`~plexapi.playlist.Playlist.item`. """
|
""" Alias to :func:`~plexapi.playlist.Playlist.item`. """
|
||||||
return self.item(title)
|
return self.item(title)
|
||||||
|
|
||||||
def _getPlaylistItemID(self, item):
|
|
||||||
""" Match an item to a playlist item and return the item playlistItemID. """
|
|
||||||
for _item in self.items():
|
|
||||||
if _item.ratingKey == item.ratingKey:
|
|
||||||
return _item.playlistItemID
|
|
||||||
raise NotFound('Item with title "%s" not found in the playlist' % item.title)
|
|
||||||
|
|
||||||
def addItems(self, items):
|
def addItems(self, items):
|
||||||
""" Add items to the playlist.
|
""" Add items to the playlist.
|
||||||
|
|
||||||
|
@ -225,7 +235,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin):
|
||||||
self._server.query(key, method=self._server._session.delete)
|
self._server.query(key, method=self._server._session.delete)
|
||||||
|
|
||||||
def moveItem(self, item, after=None):
|
def moveItem(self, item, after=None):
|
||||||
""" Move an item to a new position in playlist.
|
""" Move an item to a new position in the playlist.
|
||||||
|
|
||||||
Parameters:
|
Parameters:
|
||||||
items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`,
|
||||||
|
|
|
@ -178,19 +178,19 @@ class MediaSettings(object):
|
||||||
photoQuality (int): photo quality on scale 0 to 100.
|
photoQuality (int): photo quality on scale 0 to 100.
|
||||||
photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`).
|
photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`).
|
||||||
videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`, may be empty).
|
videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`, may be empty).
|
||||||
subtitleSize (int|str): unknown, usually equals to 0, may be empty string.
|
subtitleSize (int): subtitle size on scale 0 to 100.
|
||||||
videoQuality (int): video quality on scale 0 to 100.
|
videoQuality (int): video quality on scale 0 to 100.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, maxVideoBitrate=4000, videoQuality=100, videoResolution='1280x720', audioBoost=100,
|
def __init__(self, maxVideoBitrate=4000, videoQuality=100, videoResolution='1280x720', audioBoost=100,
|
||||||
musicBitrate=192, photoQuality=74, photoResolution='1920x1080', subtitleSize=''):
|
musicBitrate=192, photoQuality=74, photoResolution='1920x1080', subtitleSize=100):
|
||||||
self.audioBoost = plexapi.utils.cast(int, audioBoost)
|
self.audioBoost = plexapi.utils.cast(int, audioBoost)
|
||||||
self.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) if maxVideoBitrate != '' else ''
|
self.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) if maxVideoBitrate != '' else ''
|
||||||
self.musicBitrate = plexapi.utils.cast(int, musicBitrate) if musicBitrate != '' else ''
|
self.musicBitrate = plexapi.utils.cast(int, musicBitrate) if musicBitrate != '' else ''
|
||||||
self.photoQuality = plexapi.utils.cast(int, photoQuality) if photoQuality != '' else ''
|
self.photoQuality = plexapi.utils.cast(int, photoQuality) if photoQuality != '' else ''
|
||||||
self.photoResolution = photoResolution
|
self.photoResolution = photoResolution
|
||||||
self.videoResolution = videoResolution
|
self.videoResolution = videoResolution
|
||||||
self.subtitleSize = subtitleSize
|
self.subtitleSize = plexapi.utils.cast(int, subtitleSize) if subtitleSize != '' else ''
|
||||||
self.videoQuality = plexapi.utils.cast(int, videoQuality) if videoQuality != '' else ''
|
self.videoQuality = plexapi.utils.cast(int, videoQuality) if videoQuality != '' else ''
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
@ -148,6 +148,7 @@ def searchType(libtype):
|
||||||
Parameters:
|
Parameters:
|
||||||
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track,
|
libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track,
|
||||||
collection)
|
collection)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
||||||
"""
|
"""
|
||||||
|
@ -159,6 +160,24 @@ def searchType(libtype):
|
||||||
raise NotFound('Unknown libtype: %s' % libtype)
|
raise NotFound('Unknown libtype: %s' % libtype)
|
||||||
|
|
||||||
|
|
||||||
|
def reverseSearchType(libtype):
|
||||||
|
""" Returns the string value of the library type.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
libtype (int): Integer value of the library type.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
:exc:`~plexapi.exceptions.NotFound`: Unknown libtype
|
||||||
|
"""
|
||||||
|
if libtype in SEARCHTYPES:
|
||||||
|
return libtype
|
||||||
|
libtype = int(libtype)
|
||||||
|
for k, v in SEARCHTYPES.items():
|
||||||
|
if libtype == v:
|
||||||
|
return k
|
||||||
|
raise NotFound('Unknown libtype: %s' % libtype)
|
||||||
|
|
||||||
|
|
||||||
def threaded(callback, listargs):
|
def threaded(callback, listargs):
|
||||||
""" Returns the result of <callback> for each set of `*args` in listargs. Each call
|
""" Returns the result of <callback> for each set of `*args` in listargs. Each call
|
||||||
to <callback> is called concurrently in their own separate threads.
|
to <callback> is called concurrently in their own separate threads.
|
||||||
|
|
|
@ -767,7 +767,9 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
|
||||||
parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
parentThumb (str): URL to season thumbnail image (/library/metadata/<parentRatingKey>/thumb/<thumbid>).
|
||||||
parentTitle (str): Name of the season for the episode.
|
parentTitle (str): Name of the season for the episode.
|
||||||
parentYear (int): Year the season was released.
|
parentYear (int): Year the season was released.
|
||||||
|
producers (List<:class:`~plexapi.media.Producer`>): List of producers objects.
|
||||||
rating (float): Episode rating (7.9; 9.8; 8.1).
|
rating (float): Episode rating (7.9; 9.8; 8.1).
|
||||||
|
roles (List<:class:`~plexapi.media.Role`>): List of role objects.
|
||||||
skipParent (bool): True if the show's seasons are set to hidden.
|
skipParent (bool): True if the show's seasons are set to hidden.
|
||||||
viewOffset (int): View offset in milliseconds.
|
viewOffset (int): View offset in milliseconds.
|
||||||
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
writers (List<:class:`~plexapi.media.Writer`>): List of writers objects.
|
||||||
|
@ -809,7 +811,9 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
|
||||||
self.parentThumb = data.attrib.get('parentThumb')
|
self.parentThumb = data.attrib.get('parentThumb')
|
||||||
self.parentTitle = data.attrib.get('parentTitle')
|
self.parentTitle = data.attrib.get('parentTitle')
|
||||||
self.parentYear = utils.cast(int, data.attrib.get('parentYear'))
|
self.parentYear = utils.cast(int, data.attrib.get('parentYear'))
|
||||||
|
self.producers = self.findItems(data, media.Producer)
|
||||||
self.rating = utils.cast(float, data.attrib.get('rating'))
|
self.rating = utils.cast(float, data.attrib.get('rating'))
|
||||||
|
self.roles = self.findItems(data, media.Role)
|
||||||
self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0'))
|
self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0'))
|
||||||
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0))
|
||||||
self.writers = self.findItems(data, media.Writer)
|
self.writers = self.findItems(data, media.Writer)
|
||||||
|
@ -838,6 +842,11 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
|
||||||
""" Returns a human friendly filename. """
|
""" Returns a human friendly filename. """
|
||||||
return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode)
|
return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def actors(self):
|
||||||
|
""" Alias to self.roles. """
|
||||||
|
return self.roles
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def locations(self):
|
def locations(self):
|
||||||
""" This does not exist in plex xml response but is added to have a common
|
""" This does not exist in plex xml response but is added to have a common
|
||||||
|
@ -865,6 +874,11 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin,
|
||||||
""" Returns the s00e00 string containing the season and episode numbers. """
|
""" Returns the s00e00 string containing the season and episode numbers. """
|
||||||
return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.episodeNumber).zfill(2))
|
return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.episodeNumber).zfill(2))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hasCommercialMarker(self):
|
||||||
|
""" Returns True if the episode has a commercial marker in the xml. """
|
||||||
|
return any(marker.type == 'commercial' for marker in self.markers)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hasIntroMarker(self):
|
def hasIntroMarker(self):
|
||||||
""" Returns True if the episode has an intro marker in the xml. """
|
""" Returns True if the episode has an intro marker in the xml. """
|
||||||
|
|
|
@ -2,4 +2,4 @@ apscheduler==3.6.3
|
||||||
pyinstaller==4.2
|
pyinstaller==4.2
|
||||||
pyopenssl==20.0.1
|
pyopenssl==20.0.1
|
||||||
pycryptodomex==3.9.9
|
pycryptodomex==3.9.9
|
||||||
pywin32==300
|
pywin32==301
|
||||||
|
|
|
@ -566,6 +566,7 @@ class Export(object):
|
||||||
'guids': {
|
'guids': {
|
||||||
'id': None
|
'id': None
|
||||||
},
|
},
|
||||||
|
'hasCommercialMarker': None,
|
||||||
'hasIntroMarker': None,
|
'hasIntroMarker': None,
|
||||||
'hasPreviewThumbnails': None,
|
'hasPreviewThumbnails': None,
|
||||||
'index': None,
|
'index': None,
|
||||||
|
@ -732,8 +733,18 @@ class Export(object):
|
||||||
'parentThumb': None,
|
'parentThumb': None,
|
||||||
'parentTitle': None,
|
'parentTitle': None,
|
||||||
'parentYear': None,
|
'parentYear': None,
|
||||||
|
'producers': {
|
||||||
|
'id': None,
|
||||||
|
'tag': None
|
||||||
|
},
|
||||||
'rating': None,
|
'rating': None,
|
||||||
'ratingKey': None,
|
'ratingKey': None,
|
||||||
|
'roles': {
|
||||||
|
'id': None,
|
||||||
|
'tag': None,
|
||||||
|
'role': None,
|
||||||
|
'thumb': None
|
||||||
|
},
|
||||||
'seasonEpisode': None,
|
'seasonEpisode': None,
|
||||||
'seasonNumber': None,
|
'seasonNumber': None,
|
||||||
'summary': None,
|
'summary': None,
|
||||||
|
@ -1318,10 +1329,11 @@ class Export(object):
|
||||||
'rating', 'audienceRating', 'audienceRatingImage', 'userRating', 'contentRating',
|
'rating', 'audienceRating', 'audienceRatingImage', 'userRating', 'contentRating',
|
||||||
'summary', 'guid', 'duration', 'durationHuman', 'type', 'episodeNumber', 'seasonEpisode',
|
'summary', 'guid', 'duration', 'durationHuman', 'type', 'episodeNumber', 'seasonEpisode',
|
||||||
'parentTitle', 'parentRatingKey', 'parentGuid', 'parentYear', 'seasonNumber',
|
'parentTitle', 'parentRatingKey', 'parentGuid', 'parentYear', 'seasonNumber',
|
||||||
'grandparentTitle', 'grandparentRatingKey', 'grandparentGuid', 'hasIntroMarker'
|
'grandparentTitle', 'grandparentRatingKey', 'grandparentGuid',
|
||||||
|
'hasCommercialMarker', 'hasIntroMarker'
|
||||||
],
|
],
|
||||||
2: [
|
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',
|
'fields.name', 'fields.locked', 'guids.id',
|
||||||
'markers.type', 'markers.start', 'markers.end'
|
'markers.type', 'markers.start', 'markers.end'
|
||||||
],
|
],
|
||||||
|
@ -1815,7 +1827,11 @@ class Export(object):
|
||||||
# Only playlists export allowed for users
|
# Only playlists export allowed for users
|
||||||
items = self.obj.playlists()
|
items = self.obj.playlists()
|
||||||
else:
|
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()
|
items = method()
|
||||||
|
|
||||||
self.total_items = len(items)
|
self.total_items = len(items)
|
||||||
|
|
|
@ -1840,6 +1840,11 @@ def str_eval(field_name, kwargs):
|
||||||
class CustomFormatter(Formatter):
|
class CustomFormatter(Formatter):
|
||||||
def __init__(self, default='{{{0}}}'):
|
def __init__(self, default='{{{0}}}'):
|
||||||
self.default = default
|
self.default = default
|
||||||
|
self.eval_regex = re.compile(r'`.*?`')
|
||||||
|
self.eval_replace = {
|
||||||
|
':': '%%colon%%',
|
||||||
|
'!': '%%exclamation%%'
|
||||||
|
}
|
||||||
|
|
||||||
def convert_field(self, value, conversion):
|
def convert_field(self, value, conversion):
|
||||||
if conversion is None:
|
if conversion is None:
|
||||||
|
@ -1887,8 +1892,21 @@ class CustomFormatter(Formatter):
|
||||||
return super(CustomFormatter, self).get_value(key, args, kwargs)
|
return super(CustomFormatter, self).get_value(key, args, kwargs)
|
||||||
|
|
||||||
def parse(self, format_string):
|
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)
|
parsed = super(CustomFormatter, self).parse(format_string)
|
||||||
|
|
||||||
for literal_text, field_name, format_spec, conversion in parsed:
|
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 = ''
|
real_format_string = ''
|
||||||
if field_name:
|
if field_name:
|
||||||
real_format_string += field_name
|
real_format_string += field_name
|
||||||
|
@ -1900,8 +1918,8 @@ class CustomFormatter(Formatter):
|
||||||
prefix = None
|
prefix = None
|
||||||
suffix = None
|
suffix = None
|
||||||
|
|
||||||
matches = re.findall(r'`.*?`', real_format_string)
|
matches = re.findall(self.eval_regex, real_format_string)
|
||||||
temp_format_string = re.sub(r'`.*`', '{}', real_format_string)
|
temp_format_string = re.sub(self.eval_regex, '{}', real_format_string)
|
||||||
|
|
||||||
prefix_split = temp_format_string.split('<')
|
prefix_split = temp_format_string.split('<')
|
||||||
if len(prefix_split) == 2:
|
if len(prefix_split) == 2:
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue