From 6a408ea12a078e7dec3b30bc0777bede23b5a1b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Jul 2021 20:24:03 +0000 Subject: [PATCH 01/15] Bump joncloud/makensis-action from 3.4 to 3.6 Bumps [joncloud/makensis-action](https://github.com/joncloud/makensis-action) from 3.4 to 3.6. - [Release notes](https://github.com/joncloud/makensis-action/releases) - [Commits](https://github.com/joncloud/makensis-action/compare/v3.4...v3.6) --- updated-dependencies: - dependency-name: joncloud/makensis-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/publish-installers.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-installers.yml b/.github/workflows/publish-installers.yml index 90c845e2..12b7147a 100644 --- a/.github/workflows/publish-installers.yml +++ b/.github/workflows/publish-installers.yml @@ -72,7 +72,7 @@ jobs: pyinstaller -y ./package/Tautulli-${{ matrix.os }}.spec - name: Create Windows Installer - uses: joncloud/makensis-action@v3.4 + uses: joncloud/makensis-action@v3.6 if: matrix.os == 'windows' with: script-file: ./package/Tautulli.nsi From 150aa7b2e71dc0f2126e93fffe3145f1ab0f6f49 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Jul 2021 20:24:08 +0000 Subject: [PATCH 02/15] Bump technote-space/workflow-conclusion-action from 1 to 2.1.7 Bumps [technote-space/workflow-conclusion-action](https://github.com/technote-space/workflow-conclusion-action) from 1 to 2.1.7. - [Release notes](https://github.com/technote-space/workflow-conclusion-action/releases) - [Changelog](https://github.com/technote-space/workflow-conclusion-action/blob/master/.releasegarc) - [Commits](https://github.com/technote-space/workflow-conclusion-action/compare/v1...v2.1.7) --- updated-dependencies: - dependency-name: technote-space/workflow-conclusion-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/publish-docker.yml | 2 +- .github/workflows/publish-installers.yml | 4 ++-- .github/workflows/publish-snap.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 876c46b9..c5488c92 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -94,7 +94,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v1 + uses: technote-space/workflow-conclusion-action@v2.1.7 - name: Combine Job Status id: status diff --git a/.github/workflows/publish-installers.yml b/.github/workflows/publish-installers.yml index 90c845e2..40b8e3a6 100644 --- a/.github/workflows/publish-installers.yml +++ b/.github/workflows/publish-installers.yml @@ -104,7 +104,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v1 + uses: technote-space/workflow-conclusion-action@v2.1.7 - name: Checkout Code uses: actions/checkout@v2 @@ -168,7 +168,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v1 + uses: technote-space/workflow-conclusion-action@v2.1.7 - name: Combine Job Status id: status diff --git a/.github/workflows/publish-snap.yml b/.github/workflows/publish-snap.yml index ba43e153..83ce5b97 100644 --- a/.github/workflows/publish-snap.yml +++ b/.github/workflows/publish-snap.yml @@ -73,7 +73,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Get Build Job Status - uses: technote-space/workflow-conclusion-action@v1 + uses: technote-space/workflow-conclusion-action@v2.1.7 - name: Combine Job Status id: status From e0077f9386efe7aaecfd340f9417df7a70be310e Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 15 Jul 2021 13:33:24 -0700 Subject: [PATCH 03/15] Fix changelog headers --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bb78a5a..cedc939f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -# v2.7.5 (2021-07-15) +## v2.7.5 (2021-07-15) * History: * Fix: Guest users were unable to view history. @@ -17,7 +17,7 @@ * Remove: Basic Authentication setting. -# v2.7.4 (2021-06-19) +## v2.7.4 (2021-06-19) * Activity: * Fix: Incorrect quality profile shown on the activity card. From 81ff471149c66d2b14106352c27cd0b00457e77d Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 22 Jul 2021 17:46:52 -0700 Subject: [PATCH 04/15] Fix typo in newsletter config --- data/interfaces/default/newsletter_config.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/interfaces/default/newsletter_config.html b/data/interfaces/default/newsletter_config.html index 9ee1e96d..458b0fd8 100644 --- a/data/interfaces/default/newsletter_config.html +++ b/data/interfaces/default/newsletter_config.html @@ -176,7 +176,7 @@

Optional: Enter a unique ID name to create a static URL to the last sent scheduled newsletter at ${http_root}newsletter/id/<id_name>. Only letters (a-z), numbers (0-9), underscores (_) and hyphens (-) are allowed. Leave blank to disable.
- Note: Test newsletters are not considered as scheduled newsletters. + Note: Test newsletters are not considered scheduled newsletters.

From 3c1417108d92e207e0d6b3d7cb6d046f201e3463 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Thu, 22 Jul 2021 17:47:27 -0700 Subject: [PATCH 05/15] Use hmac compare_digest to check password --- lib/hashing_passwords.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/hashing_passwords.py b/lib/hashing_passwords.py index 93ae5e12..4540db75 100644 --- a/lib/hashing_passwords.py +++ b/lib/hashing_passwords.py @@ -20,6 +20,7 @@ import hashlib from os import urandom from base64 import b64encode, b64decode from hashlib import pbkdf2_hmac +from hmac import compare_digest # Parameters to PBKDF2. Only affect new passwords. @@ -53,9 +54,4 @@ def check_hash(password, hash_): hash_a = b64decode(hash_a.encode('utf-8')) hash_b = pbkdf2_hmac(hash_function, password, salt.encode('utf-8'), int(cost_factor), len(hash_a)) assert len(hash_a) == len(hash_b) # we requested this from pbkdf2_bin() - # Same as "return hash_a == hash_b" but takes a constant time. - # See http://carlos.bueno.org/2011/10/timing.html - diff = 0 - for char_a, char_b in zip(bytearray(hash_a), bytearray(hash_b)): - diff |= char_a ^ char_b - return diff == 0 + return compare_digest(hash_a, hash_b) From 05a00e987221236914285023afa891cb383f4a2c Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 28 Jul 2021 17:43:21 -0700 Subject: [PATCH 06/15] Fix colon in notification text eval being parsed as format specifier --- plexpy/notification_handler.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py index 05e541ba..f696b5ed 100644 --- a/plexpy/notification_handler.py +++ b/plexpy/notification_handler.py @@ -1889,6 +1889,12 @@ class CustomFormatter(Formatter): def parse(self, format_string): parsed = super(CustomFormatter, self).parse(format_string) for literal_text, field_name, format_spec, conversion in parsed: + # Fix colon (:) inside an eval expression being parsed as a format specifier + if (field_name and field_name.startswith('`') and + format_spec and format_spec.endswith('`')): + field_name += ':' + format_spec + format_spec = '' + real_format_string = '' if field_name: real_format_string += field_name From 670d2b31f75b4b5a4be39843aa972bf881b653c2 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Wed, 28 Jul 2021 18:15:51 -0700 Subject: [PATCH 07/15] Replace colon and exclamation in notification eval expression --- plexpy/notification_handler.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/plexpy/notification_handler.py b/plexpy/notification_handler.py index f696b5ed..0f2155ad 100644 --- a/plexpy/notification_handler.py +++ b/plexpy/notification_handler.py @@ -1840,6 +1840,11 @@ def str_eval(field_name, kwargs): class CustomFormatter(Formatter): def __init__(self, default='{{{0}}}'): self.default = default + self.eval_regex = re.compile(r'`.*?`') + self.eval_replace = { + ':': '%%colon%%', + '!': '%%exclamation%%' + } def convert_field(self, value, conversion): if conversion is None: @@ -1887,13 +1892,20 @@ 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: - # Fix colon (:) inside an eval expression being parsed as a format specifier - if (field_name and field_name.startswith('`') and - format_spec and format_spec.endswith('`')): - field_name += ':' + format_spec - format_spec = '' + # 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: @@ -1906,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: From c06c046c480cb8e61c918149a9864f922dc2391f Mon Sep 17 00:00:00 2001 From: TheMeanCanEHdian Date: Sat, 7 Aug 2021 10:12:30 -0500 Subject: [PATCH 08/15] Convert issue templates to forms. --- .github/ISSUE_TEMPLATE/BUG-REPORT.yml | 96 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml | 31 +++++++ .github/ISSUE_TEMPLATE/bug_report.md | 49 ----------- .github/ISSUE_TEMPLATE/feature_request.md | 20 ----- 4 files changed, 127 insertions(+), 69 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/BUG-REPORT.yml create mode 100644 .github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml new file mode 100644 index 00000000..ccc2331b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -0,0 +1,96 @@ +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 + placeholder: A clear and concise description of the bug. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. Scroll down to '...' + 4. See error + - type: textarea + id: expected + attributes: + label: Expected Behavior + placeholder: A clear and concise description of what you expected to happen. + - type: textarea + id: screenshots + attributes: + label: Screenshots + placeholder: Provide screenshots to help explain your problem. + - type: textarea + id: relevant + attributes: + label: Relevant 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 + placeholder: ex. v2.7.5 (check Tautulli Settings > Help & Info page) + validations: + required: true + - type: input + id: branch + attributes: + label: Git Branch + placeholder: ex. master (check Tautulli Settings > Help & Info page) + validations: + required: true + - type: input + id: hash + attributes: + label: Git Commit Hash + placeholder: ex. `2cc5bf812fe05e0666aeaeb37ed550c59816fb4c` (check Tautulli Settings > Help & Info page) + validations: + required: true + - type: input + id: platform + attributes: + label: Platform and Version + placeholder: ex. Windows 10 (check Tautulli Settings > Help & Info page) + validations: + required: true + - type: input + id: python + attributes: + label: Python Version + placeholder: ex. 3.8.10 (check Tautulli Settings > Help & Info page) + validations: + required: true + - type: input + id: browser + attributes: + label: Browser and Version + placeholder: ex. Chrome 88 + validations: + required: true + - type: input + id: logs + attributes: + label: Link to logs + placeholder: Include a link to your **FULL** logs (not just a few lines) on [Gist](http://gist.github.com). _Do not upload attachments_. + validations: + required: true + - type: markdown + attributes: + value: | + Make sure to close your issue when it's solved! If you found the solution yourself please comment so that others benefit from it. diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml new file mode 100644 index 00000000..a677c627 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml @@ -0,0 +1,31 @@ +name: Feature Request +description: Suggest a new feature for Tautulli. +labels: ['status:awaiting-triage', 'type:enhancement'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to help improve Tautulli! + - type: textarea + id: problem + attributes: + label: Is your feature request related to a problem? + description: If so, please describe the problem. + placeholder: A clear and concise description of what the problem (if any) is. Ex. I'm always frustrated when '...' + - type: textarea + id: feature + attributes: + label: What is your feature request? + placeholder: A clear and concise description of the feature. + validations: + required: true + - type: textarea + id: workaround + attributes: + label: Are there any workarounds? + placeholder: A clear and concise description of any alternative solutions or features you've considered. + - type: textarea + id: additional + attributes: + label: Additional Context + placeholder: Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 0ce4d436..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -name: Bug Report -about: Please do not use bug reports for support issues. -title: '' -labels: 'status:awaiting-triage, type:bug' -assignees: '' - ---- - - - -**Describe the Bug** -A clear and concise description of what the bug is. - -**Steps to Reproduce** -1. Go to '...' -2. Click on '...' -3. Scroll down to '...' -4. See error - -**Expected Behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -Provide screenshots to help explain your problem. - -**Relevant Settings** -- eg. Plex Media Server IP address/port/checkboxes/proxy/etc. -- eg. Notification agent configuration/triggers/conditions/text/delay/grouping/etc. -- eg. Newsletter agent configuration/checkboxes/template/etc. -- Other settings - -**Tautulli and System Info (see Tautulli settings page)** -- Version: [eg. v2.6.6] -- Git Branch: [eg. master] -- Git Commit Hash: [eg. 2cc5bf812fe05e0666aeaeb37ed550c59816fb4c] -- Platform and Version: [eg. Windows 10] -- Python Version: [e.g. 3.8.8] -- Browser and Version: [e.g. Chrome 88] - -**Link to logs (required)** -Include a link to your **FULL** logs (not just a few lines) on [Gist](http://gist.github.com). _Do not upload attachments_. - - diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 747aefd6..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature Request -about: Suggest a new feature for Tautulli. -title: '' -labels: 'status:awaiting-triage, type:enhancement' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. From 2aaa15793af48349eb39fb6f2aa0a558137482e2 Mon Sep 17 00:00:00 2001 From: TheMeanCanEHdian Date: Sat, 7 Aug 2021 10:25:14 -0500 Subject: [PATCH 09/15] Fix formatting of placeholders and use descriptions. --- .github/ISSUE_TEMPLATE/BUG-REPORT.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml index ccc2331b..79eba6ec 100644 --- a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -45,35 +45,40 @@ body: id: version attributes: label: Tautulli Version - placeholder: ex. v2.7.5 (check Tautulli Settings > Help & Info page) + description: Check Tautulli Settings > Help & Info page. + placeholder: ex. v2.7.5 validations: required: true - type: input id: branch attributes: label: Git Branch - placeholder: ex. master (check Tautulli Settings > Help & Info page) + description: Check Tautulli Settings > Help & Info page. + placeholder: ex. master validations: required: true - type: input id: hash attributes: label: Git Commit Hash - placeholder: ex. `2cc5bf812fe05e0666aeaeb37ed550c59816fb4c` (check Tautulli Settings > Help & Info page) + description: Check Tautulli Settings > Help & Info page. + placeholder: ex. 2cc5bf812fe05e0666aeaeb37ed550c59816fb4c validations: required: true - type: input id: platform attributes: label: Platform and Version - placeholder: ex. Windows 10 (check Tautulli Settings > Help & Info page) + description: Check Tautulli Settings > Help & Info page. + placeholder: ex. Windows 10 validations: required: true - type: input id: python attributes: label: Python Version - placeholder: ex. 3.8.10 (check Tautulli Settings > Help & Info page) + description: Check Tautulli Settings > Help & Info page. + placeholder: ex. 3.8.10 validations: required: true - type: input @@ -87,7 +92,8 @@ body: id: logs attributes: label: Link to logs - placeholder: Include a link to your **FULL** logs (not just a few lines) on [Gist](http://gist.github.com). _Do not upload attachments_. + description: Include a link to your **FULL** logs (not just a few lines) on [Gist](http://gist.github.com). _Do not upload attachments_. + placeholder: Gist link to logs validations: required: true - type: markdown From f28a4e92b45da4579e2f473b9af547482adde640 Mon Sep 17 00:00:00 2001 From: TheMeanCanEHdian Date: Sat, 7 Aug 2021 11:17:23 -0500 Subject: [PATCH 10/15] Update various formatting items. - Change `ex.` to `eg.` - Move non-example placeholders to descriptions - Add description to Steps to Reproduce & Relevant Settings --- .github/ISSUE_TEMPLATE/BUG-REPORT.yml | 25 +++++++++++----------- .github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml | 10 ++++----- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml index 79eba6ec..34f617df 100644 --- a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -10,13 +10,14 @@ body: id: description attributes: label: Describe the Bug - placeholder: A clear and concise description of 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 '...' @@ -26,16 +27,17 @@ body: id: expected attributes: label: Expected Behavior - placeholder: A clear and concise description of what you expected to happen. + description: A clear and concise description of what you expected to happen. - type: textarea id: screenshots attributes: label: Screenshots - placeholder: Provide screenshots to help explain your problem. + 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. @@ -46,7 +48,7 @@ body: attributes: label: Tautulli Version description: Check Tautulli Settings > Help & Info page. - placeholder: ex. v2.7.5 + placeholder: eg. v2.7.5 validations: required: true - type: input @@ -54,7 +56,7 @@ body: attributes: label: Git Branch description: Check Tautulli Settings > Help & Info page. - placeholder: ex. master + placeholder: eg. master validations: required: true - type: input @@ -62,7 +64,7 @@ body: attributes: label: Git Commit Hash description: Check Tautulli Settings > Help & Info page. - placeholder: ex. 2cc5bf812fe05e0666aeaeb37ed550c59816fb4c + placeholder: eg. 2cc5bf812fe05e0666aeaeb37ed550c59816fb4c validations: required: true - type: input @@ -70,7 +72,7 @@ body: attributes: label: Platform and Version description: Check Tautulli Settings > Help & Info page. - placeholder: ex. Windows 10 + placeholder: eg. Windows 10 validations: required: true - type: input @@ -78,22 +80,21 @@ body: attributes: label: Python Version description: Check Tautulli Settings > Help & Info page. - placeholder: ex. 3.8.10 + placeholder: eg. 3.8.10 validations: required: true - type: input id: browser attributes: label: Browser and Version - placeholder: ex. Chrome 88 + 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). _Do not upload attachments_. - placeholder: Gist link to logs + 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 diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml index a677c627..70e2ab33 100644 --- a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml +++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml @@ -10,22 +10,22 @@ body: id: problem attributes: label: Is your feature request related to a problem? - description: If so, please describe the problem. - placeholder: A clear and concise description of what the problem (if any) is. Ex. I'm always frustrated when '...' + 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? - placeholder: A clear and concise description of the feature. + description: A clear and concise description of the feature. validations: required: true - type: textarea id: workaround attributes: label: Are there any workarounds? - placeholder: A clear and concise description of any alternative solutions or features you've considered. + description: A clear and concise description of any alternative solutions or features you've considered. - type: textarea id: additional attributes: label: Additional Context - placeholder: Add any other context or screenshots about the feature request here. + description: Add any other context or screenshots about the feature request here. From d5ea507bacf074de9313810a9ffdf685cb388c3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Aug 2021 20:57:11 +0000 Subject: [PATCH 11/15] Bump pywin32 from 300 to 301 in /package Bumps [pywin32](https://github.com/mhammond/pywin32) from 300 to 301. - [Release notes](https://github.com/mhammond/pywin32/releases) - [Changelog](https://github.com/mhammond/pywin32/blob/master/CHANGES.txt) - [Commits](https://github.com/mhammond/pywin32/commits) --- updated-dependencies: - dependency-name: pywin32 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- package/requirements-windows.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package/requirements-windows.txt b/package/requirements-windows.txt index 858b0595..9719ad32 100644 --- a/package/requirements-windows.txt +++ b/package/requirements-windows.txt @@ -2,4 +2,4 @@ apscheduler==3.6.3 pyinstaller==4.2 pyopenssl==20.0.1 pycryptodomex==3.9.9 -pywin32==300 +pywin32==301 From fd9ed1291d55ebe5854fc1c9d5a29d19884a6455 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 15 Aug 2021 15:10:01 -0700 Subject: [PATCH 12/15] Ignore dependencies for stale workflow [skip ci] --- .github/workflows/issues-stale.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issues-stale.yml b/.github/workflows/issues-stale.yml index 126af260..14f631bf 100644 --- a/.github/workflows/issues-stale.yml +++ b/.github/workflows/issues-stale.yml @@ -25,7 +25,7 @@ jobs: close-pr-message: > This PR was closed because it has been stalled for 5 days with no activity. stale-pr-label: 'stale' - exempt-pr-labels: 'status:in-progress' + exempt-pr-labels: 'status:in-progress,dependencies' days-before-stale: 30 days-before-close: 5 @@ -43,7 +43,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' skip-stale-pr-message: true only-labels: 'invalid:template-incomplete' days-before-stale: 0 From 4ec8ef3ab3ee7256483c0b6d8e698d2ace448c4d Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 15 Aug 2021 15:17:20 -0700 Subject: [PATCH 13/15] Fix exporting collections and playlists Fixes #1484 --- plexpy/exporter.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plexpy/exporter.py b/plexpy/exporter.py index 674296a5..9f5584c4 100644 --- a/plexpy/exporter.py +++ b/plexpy/exporter.py @@ -1815,7 +1815,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) From 3fe77932a0db4ad001369b2509674d89e59c9250 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 15 Aug 2021 15:23:53 -0700 Subject: [PATCH 14/15] Update PlexAPI to 4.6.3 --- lib/plexapi/collection.py | 36 ++++++++++++++- lib/plexapi/library.py | 94 ++++++++++++++++++++++++++------------- lib/plexapi/media.py | 4 +- lib/plexapi/mixins.py | 60 ++++++++++++++++++++++++- lib/plexapi/myplex.py | 72 +++++++++++++++++++++--------- lib/plexapi/playlist.py | 30 ++++++++----- lib/plexapi/sync.py | 6 +-- lib/plexapi/utils.py | 19 ++++++++ lib/plexapi/video.py | 14 ++++++ 9 files changed, 265 insertions(+), 70 deletions(-) diff --git a/lib/plexapi/collection.py b/lib/plexapi/collection.py index 0eb20924..1d0e1260 100644 --- a/lib/plexapi/collection.py +++ b/lib/plexapi/collection.py @@ -6,13 +6,13 @@ from plexapi.base import PlexPartialObject from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection from plexapi.mixins import AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin -from plexapi.mixins import LabelMixin +from plexapi.mixins import LabelMixin, SmartFilterMixin from plexapi.playqueue import PlayQueue from plexapi.utils import deprecated @utils.registerPlexObject -class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin): +class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin, RatingMixin, LabelMixin, SmartFilterMixin): """ Represents a single Collection. Attributes: @@ -90,6 +90,7 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin self.userRating = utils.cast(float, data.attrib.get('userRating')) self._items = None # cache for self.items self._section = None # cache for self.section + self._filters = None # cache for self.filters def __len__(self): # pragma: no cover return len(self.items()) @@ -141,6 +142,15 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin def children(self): return self.items() + def filters(self): + """ Returns the search filter dict for smart collection. + The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search` + to get the list of items. + """ + if self.smart and self._filters is None: + self._filters = self._parseFilters(self.content) + return self._filters + def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this collection belongs to. """ @@ -277,6 +287,28 @@ class Collection(PlexPartialObject, AdvancedSettingsMixin, ArtMixin, PosterMixin key = '%s/items/%s' % (self.key, item.ratingKey) self._server.query(key, method=self._server._session.delete) + def moveItem(self, item, after=None): + """ Move an item to a new position in the collection. + + Parameters: + items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to be moved in the collection. + after (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, + or :class:`~plexapi.photo.Photo` objects to move the item after in the collection. + + Raises: + :class:`plexapi.exceptions.BadRequest`: When trying to move items in a smart collection. + """ + if self.smart: + raise BadRequest('Cannot move items in a smart collection.') + + key = '%s/items/%s/move' % (self.key, item.ratingKey) + + if after: + key += '?after=%s' % after.ratingKey + + self._server.query(key, method=self._server._session.put) + def updateFilters(self, libtype=None, limit=None, sort=None, filters=None, **kwargs): """ Update the filters for a smart collection. diff --git a/lib/plexapi/library.py b/lib/plexapi/library.py index 006fda70..2b60144e 100644 --- a/lib/plexapi/library.py +++ b/lib/plexapi/library.py @@ -847,12 +847,6 @@ class LibrarySection(PlexObject): values = [values] fieldType = self.getFieldType(filterField.type) - choiceTypes = {'tag', 'subtitleLanguage', 'audioLanguage', 'resolution'} - if fieldType.type in choiceTypes: - filterChoices = self.listFilterChoices(filterField.key, libtype) - else: - filterChoices = [] - results = [] try: @@ -865,11 +859,8 @@ class LibrarySection(PlexObject): value = float(value) if '.' in str(value) else int(value) elif fieldType.type == 'string': value = str(value) - elif fieldType.type in choiceTypes: - value = str((value.id or value.tag) if isinstance(value, media.MediaTag) else value) - matchValue = value.lower() - value = next((f.key for f in filterChoices - if matchValue in {f.key.lower(), f.title.lower()}), value) + elif fieldType.type in {'tag', 'subtitleLanguage', 'audioLanguage', 'resolution'}: + value = self._validateFieldValueTag(value, filterField, libtype) results.append(str(value)) except (ValueError, AttributeError): raise BadRequest('Invalid value "%s" for filter field "%s", value should be type %s' @@ -888,24 +879,47 @@ class LibrarySection(PlexObject): else: return int(utils.toDatetime(value, '%Y-%m-%d').timestamp()) + def _validateFieldValueTag(self, value, filterField, libtype): + """ Validates a filter tag value. A filter tag value can be a :class:`~plexapi.library.FilterChoice` object, + a :class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*), + or the exact id :attr:`MediaTag.id` (*int*). + """ + if isinstance(value, FilterChoice): + return value.key + if isinstance(value, media.MediaTag): + value = str(value.id or value.tag) + else: + value = str(value) + filterChoices = self.listFilterChoices(filterField.key, libtype) + matchValue = value.lower() + return next((f.key for f in filterChoices if matchValue in {f.key.lower(), f.title.lower()}), value) + def _validateSortFields(self, sort, libtype=None): - """ Validates a list of filter sort fields is available for the library. + """ Validates a list of filter sort fields is available for the library. Sort fields can be a + list of :class:`~plexapi.library.FilteringSort` objects, or a comma separated string. Returns the validated comma separated sort fields string. """ if isinstance(sort, str): sort = sort.split(',') + if not isinstance(sort, (list, tuple)): + sort = [sort] + validatedSorts = [] for _sort in sort: - validatedSorts.append(self._validateSortField(_sort.strip(), libtype)) + validatedSorts.append(self._validateSortField(_sort, libtype)) return ','.join(validatedSorts) def _validateSortField(self, sort, libtype=None): - """ Validates a filter sort field is available for the library. + """ Validates a filter sort field is available for the library. A sort field can be a + :class:`~plexapi.library.FilteringSort` object, or a string. Returns the validated sort field string. """ - match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+):?([a-zA-Z]*)', sort) + if isinstance(sort, FilteringSort): + return '%s.%s:%s' % (libtype or self.TYPE, sort.key, sort.defaultDirection) + + match = re.match(r'(?:([a-zA-Z]*)\.)?([a-zA-Z]+):?([a-zA-Z]*)', sort.strip()) if not match: raise BadRequest('Invalid filter sort: %s' % sort) _libtype, sortField, sortDir = match.groups() @@ -921,16 +935,13 @@ class LibrarySection(PlexObject): sortField = libtype + '.' + filterSort.key - if not sortDir: - sortDir = filterSort.defaultDirection - - availableDirections = ['asc', 'desc', 'nullsLast'] + availableDirections = ['', 'asc', 'desc', 'nullsLast'] if sortDir not in availableDirections: raise NotFound('Unknown sort direction "%s". ' 'Available sort directions: %s' % (sortDir, availableDirections)) - return '%s:%s' % (sortField, sortDir) + return '%s:%s' % (sortField, sortDir) if sortDir else sortField def _validateAdvancedSearch(self, filters, libtype): """ Validates an advanced search filter dictionary. @@ -1009,9 +1020,8 @@ class LibrarySection(PlexObject): Parameters: title (str, optional): General string query to search for. Partial string matches are allowed. - sort (str or list, optional): A string of comma separated sort fields or a list of sort fields - in the format ``column:dir``. - See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields. + sort (:class:`~plexapi.library.FilteringSort` or str or list, optional): A field to sort the results. + See the details below for more info. maxresults (int, optional): Only return the specified number of results. libtype (str, optional): Return results of a specific type (movie, show, season, episode, artist, album, track, photoalbum, photo, collection) (e.g. ``libtype='episode'`` will only @@ -1027,6 +1037,28 @@ class LibrarySection(PlexObject): :exc:`~plexapi.exceptions.BadRequest`: When the sort or filter is invalid. :exc:`~plexapi.exceptions.NotFound`: When applying an unknown sort or filter. + **Sorting Results** + + The search results can be sorted by including the ``sort`` parameter. + + * See :func:`~plexapi.library.LibrarySection.listSorts` to get a list of available sort fields. + + The ``sort`` parameter can be a :class:`~plexapi.library.FilteringSort` object or a sort string in the + format ``field:dir``. The sort direction ``dir`` can be ``asc``, ``desc``, or ``nullsLast``. Omitting the + sort direction or using a :class:`~plexapi.library.FilteringSort` object will sort the results in the default + direction of the field. Multi-sorting on multiple fields can be achieved by using a comma separated list of + sort strings, or a list of :class:`~plexapi.library.FilteringSort` object or strings. + + Examples: + + .. code-block:: python + + library.search(sort="titleSort:desc") # Sort title in descending order + library.search(sort="titleSort") # Sort title in the default order + # Multi-sort by year in descending order, then by audience rating in descending order + library.search(sort="year:desc,audienceRating:desc") + library.search(sort=["year:desc", "audienceRating:desc"]) + **Using Plex Filters** Any of the available custom filters can be applied to the search results @@ -1065,8 +1097,9 @@ class LibrarySection(PlexObject): * **writer** (:class:`~plexapi.media.MediaTag`): Search for the name of a writer. * **year** (*int*): Search for a specific year. - Tag type filter values can be a :class:`~plexapi.media.MediaTag` object, the exact name - :attr:`MediaTag.tag` (*str*), or the exact id :attr:`MediaTag.id` (*int*). + Tag type filter values can be a :class:`~plexapi.library.FilterChoice` object, + :class:`~plexapi.media.MediaTag` object, the exact name :attr:`MediaTag.tag` (*str*), + or the exact id :attr:`MediaTag.id` (*int*). Date type filter values can be a ``datetime`` object, a relative date using a one of the available date suffixes (e.g. ``30d``) (*str*), or a date in ``YYYY-MM-DD`` (*str*) format. @@ -2014,7 +2047,6 @@ class FilteringType(PlexObject): ('guid', 'asc', 'Guid'), ('id', 'asc', 'Rating Key'), ('index', 'asc', '%s Number' % self.type.capitalize()), - ('random', 'asc', 'Random'), ('summary', 'asc', 'Summary'), ('tagline', 'asc', 'Tagline'), ('updatedAt', 'asc', 'Date Updated') @@ -2029,7 +2061,11 @@ class FilteringType(PlexObject): additionalSorts.extend([ ('absoluteIndex', 'asc', 'Absolute Index') ]) - if self.type == 'collection': + elif self.type == 'photo': + additionalSorts.extend([ + ('viewUpdatedAt', 'desc', 'View Updated At') + ]) + elif self.type == 'collection': additionalSorts.extend([ ('addedAt', 'asc', 'Date Added') ]) @@ -2081,10 +2117,6 @@ class FilteringType(PlexObject): ('rating', 'integer', 'Critic Rating'), ('viewOffset', 'integer', 'View Offset') ]) - elif self.type == 'artist': - additionalFields.extend([ - ('lastViewedAt', 'date', 'Artist Last Played') - ]) elif self.type == 'track': additionalFields.extend([ ('duration', 'integer', 'Duration'), diff --git a/lib/plexapi/media.py b/lib/plexapi/media.py index 3ca69978..addc3fdf 100644 --- a/lib/plexapi/media.py +++ b/lib/plexapi/media.py @@ -964,10 +964,12 @@ class Marker(PlexObject): name = self._clean(self.firstAttr('type')) start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start'))) end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end'))) - return '<%s:%s %s - %s>' % (self.__class__.__name__, name, start, end) + offsets = '%s-%s' % (start, end) + return '<%s>' % ':'.join([self.__class__.__name__, name, offsets]) def _loadData(self, data): self._data = data + self.id = utils.cast(int, data.attrib.get('id')) self.type = data.attrib.get('type') self.start = utils.cast(int, data.attrib.get('startTimeOffset')) self.end = utils.cast(int, data.attrib.get('endTimeOffset')) diff --git a/lib/plexapi/mixins.py b/lib/plexapi/mixins.py index 2ff12a20..b5e7e649 100644 --- a/lib/plexapi/mixins.py +++ b/lib/plexapi/mixins.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from urllib.parse import quote_plus, urlencode +from urllib.parse import parse_qsl, quote_plus, unquote, urlencode, urlsplit from plexapi import media, settings, utils from plexapi.exceptions import BadRequest, NotFound @@ -559,3 +559,61 @@ class WriterMixin(object): locked (bool): True (default) to lock the field, False to unlock the field. """ self._edit_tags('writer', writers, locked=locked, remove=True) + + +class SmartFilterMixin(object): + """ Mixing for Plex objects that can have smart filters. """ + + def _parseFilters(self, content): + """ Parse the content string and returns the filter dict. """ + content = urlsplit(unquote(content)) + filters = {} + filterOp = 'and' + filterGroups = [[]] + + for key, value in parse_qsl(content.query): + # Move = sign to key when operator is == + if value.startswith('='): + key += '=' + value = value[1:] + + if key == 'type': + filters['libtype'] = utils.reverseSearchType(value) + elif key == 'sort': + filters['sort'] = value.split(',') + elif key == 'limit': + filters['limit'] = int(value) + elif key == 'push': + filterGroups[-1].append([]) + filterGroups.append(filterGroups[-1][-1]) + elif key == 'and': + filterOp = 'and' + elif key == 'or': + filterOp = 'or' + elif key == 'pop': + filterGroups[-1].insert(0, filterOp) + filterGroups.pop() + else: + filterGroups[-1].append({key: value}) + + if filterGroups: + filters['filters'] = self._formatFilterGroups(filterGroups.pop()) + return filters + + def _formatFilterGroups(self, groups): + """ Formats the filter groups into the advanced search rules. """ + if len(groups) == 1 and isinstance(groups[0], list): + groups = groups.pop() + + filterOp = 'and' + rules = [] + + for g in groups: + if isinstance(g, list): + rules.append(self._formatFilterGroups(g)) + elif isinstance(g, dict): + rules.append(g) + elif g in {'and', 'or'}: + filterOp = g + + return {filterOp: rules} diff --git a/lib/plexapi/myplex.py b/lib/plexapi/myplex.py index fe856f34..15da2115 100644 --- a/lib/plexapi/myplex.py +++ b/lib/plexapi/myplex.py @@ -623,9 +623,7 @@ class MyPlexAccount(PlexObject): } url = SyncList.key.format(clientId=client.clientIdentifier) - data = self.query(url, method=self._session.post, headers={ - 'Content-type': 'x-www-form-urlencoded', - }, params=params) + data = self.query(url, method=self._session.post, params=params) return SyncItem(self, data, None, clientIdentifier=client.clientIdentifier) @@ -925,6 +923,10 @@ class MyPlexResource(PlexObject): TAG = 'Device' key = 'https://plex.tv/api/resources?includeHttps=1&includeRelay=1' + # Default order to prioritize available resource connections + DEFAULT_LOCATION_ORDER = ['local', 'remote', 'relay'] + DEFAULT_SCHEME_ORDER = ['https', 'http'] + def _loadData(self, data): self._data = data self.name = data.attrib.get('name') @@ -949,10 +951,51 @@ class MyPlexResource(PlexObject): self.ownerid = utils.cast(int, data.attrib.get('ownerId', 0)) self.sourceTitle = data.attrib.get('sourceTitle') # owners plex username. - def connect(self, ssl=None, timeout=None): - """ Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object. + def preferred_connections( + self, + ssl=None, + timeout=None, + locations=DEFAULT_LOCATION_ORDER, + schemes=DEFAULT_SCHEME_ORDER, + ): + """ Returns a sorted list of the available connection addresses for this resource. Often times there is more than one address specified for a server or client. - This function will prioritize local connections before remote or relay and HTTPS before HTTP. + Default behavior will prioritize local connections before remote or relay and HTTPS before HTTP. + + Parameters: + ssl (bool, optional): Set True to only connect to HTTPS connections. Set False to + only connect to HTTP connections. Set None (default) to connect to any + HTTP or HTTPS connection. + timeout (int, optional): The timeout in seconds to attempt each connection. + """ + connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations} + for connection in self.connections: + # Only check non-local connections unless we own the resource + if self.owned or (not self.owned and not connection.local): + location = 'relay' if connection.relay else ('local' if connection.local else 'remote') + if location not in locations: + continue + if 'http' in schemes: + connections_dict[location]['http'].append(connection.httpuri) + if 'https' in schemes: + connections_dict[location]['https'].append(connection.uri) + if ssl is True: schemes.remove('http') + elif ssl is False: schemes.remove('https') + connections = [] + for location in locations: + for scheme in schemes: + connections.extend(connections_dict[location][scheme]) + return connections + + def connect( + self, + ssl=None, + timeout=None, + locations=DEFAULT_LOCATION_ORDER, + schemes=DEFAULT_SCHEME_ORDER, + ): + """ Returns a new :class:`~plexapi.server.PlexServer` or :class:`~plexapi.client.PlexClient` object. + Uses `MyPlexResource.preferred_connections()` to generate the priority order of connection addresses. After trying to connect to all available addresses for this resource and assuming at least one connection was successful, the PlexServer object is built and returned. @@ -965,22 +1008,7 @@ class MyPlexResource(PlexObject): Raises: :exc:`~plexapi.exceptions.NotFound`: When unable to connect to any addresses for this resource. """ - # Keys in the order we want the connections to be sorted - locations = ['local', 'remote', 'relay'] - schemes = ['https', 'http'] - connections_dict = {location: {scheme: [] for scheme in schemes} for location in locations} - for connection in self.connections: - # Only check non-local connections unless we own the resource - if self.owned or (not self.owned and not connection.local): - location = 'relay' if connection.relay else ('local' if connection.local else 'remote') - connections_dict[location]['http'].append(connection.httpuri) - connections_dict[location]['https'].append(connection.uri) - if ssl is True: schemes.remove('http') - elif ssl is False: schemes.remove('https') - connections = [] - for location in locations: - for scheme in schemes: - connections.extend(connections_dict[location][scheme]) + connections = self.preferred_connections(ssl, timeout, locations, schemes) # Try connecting to all known resource connections in parellel, but # only return the first server (in order) that provides a response. cls = PlexServer if 'server' in self.provides else PlexClient diff --git a/lib/plexapi/playlist.py b/lib/plexapi/playlist.py index c99626d0..4bcd72c5 100644 --- a/lib/plexapi/playlist.py +++ b/lib/plexapi/playlist.py @@ -6,13 +6,13 @@ from plexapi import utils from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, NotFound, Unsupported from plexapi.library import LibrarySection -from plexapi.mixins import ArtMixin, PosterMixin +from plexapi.mixins import ArtMixin, PosterMixin, SmartFilterMixin from plexapi.playqueue import PlayQueue from plexapi.utils import deprecated @utils.registerPlexObject -class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): +class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin, SmartFilterMixin): """ Represents a single Playlist. Attributes: @@ -61,6 +61,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self._items = None # cache for self.items self._section = None # cache for self.section + self._filters = None # cache for self.filters def __len__(self): # pragma: no cover return len(self.items()) @@ -107,6 +108,22 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): """ Returns True if this is a photo playlist. """ return self.playlistType == 'photo' + def _getPlaylistItemID(self, item): + """ Match an item to a playlist item and return the item playlistItemID. """ + for _item in self.items(): + if _item.ratingKey == item.ratingKey: + return _item.playlistItemID + raise NotFound('Item with title "%s" not found in the playlist' % item.title) + + def filters(self): + """ Returns the search filter dict for smart playlist. + The filter dict be passed back into :func:`~plexapi.library.LibrarySection.search` + to get the list of items. + """ + if self.smart and self._filters is None: + self._filters = self._parseFilters(self.content) + return self._filters + def section(self): """ Returns the :class:`~plexapi.library.LibrarySection` this smart playlist belongs to. @@ -160,13 +177,6 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): """ Alias to :func:`~plexapi.playlist.Playlist.item`. """ return self.item(title) - def _getPlaylistItemID(self, item): - """ Match an item to a playlist item and return the item playlistItemID. """ - for _item in self.items(): - if _item.ratingKey == item.ratingKey: - return _item.playlistItemID - raise NotFound('Item with title "%s" not found in the playlist' % item.title) - def addItems(self, items): """ Add items to the playlist. @@ -225,7 +235,7 @@ class Playlist(PlexPartialObject, Playable, ArtMixin, PosterMixin): self._server.query(key, method=self._server._session.delete) def moveItem(self, item, after=None): - """ Move an item to a new position in playlist. + """ Move an item to a new position in the playlist. Parameters: items (obj): :class:`~plexapi.audio.Audio`, :class:`~plexapi.video.Video`, diff --git a/lib/plexapi/sync.py b/lib/plexapi/sync.py index ce60ca9f..53dc0636 100644 --- a/lib/plexapi/sync.py +++ b/lib/plexapi/sync.py @@ -178,19 +178,19 @@ class MediaSettings(object): photoQuality (int): photo quality on scale 0 to 100. photoResolution (str): maximum photo resolution, formatted as WxH (e.g. `1920x1080`). videoResolution (str): maximum video resolution, formatted as WxH (e.g. `1280x720`, may be empty). - subtitleSize (int|str): unknown, usually equals to 0, may be empty string. + subtitleSize (int): subtitle size on scale 0 to 100. videoQuality (int): video quality on scale 0 to 100. """ def __init__(self, maxVideoBitrate=4000, videoQuality=100, videoResolution='1280x720', audioBoost=100, - musicBitrate=192, photoQuality=74, photoResolution='1920x1080', subtitleSize=''): + musicBitrate=192, photoQuality=74, photoResolution='1920x1080', subtitleSize=100): self.audioBoost = plexapi.utils.cast(int, audioBoost) self.maxVideoBitrate = plexapi.utils.cast(int, maxVideoBitrate) if maxVideoBitrate != '' else '' self.musicBitrate = plexapi.utils.cast(int, musicBitrate) if musicBitrate != '' else '' self.photoQuality = plexapi.utils.cast(int, photoQuality) if photoQuality != '' else '' self.photoResolution = photoResolution self.videoResolution = videoResolution - self.subtitleSize = subtitleSize + self.subtitleSize = plexapi.utils.cast(int, subtitleSize) if subtitleSize != '' else '' self.videoQuality = plexapi.utils.cast(int, videoQuality) if videoQuality != '' else '' @staticmethod diff --git a/lib/plexapi/utils.py b/lib/plexapi/utils.py index 5fe31caa..310200f6 100644 --- a/lib/plexapi/utils.py +++ b/lib/plexapi/utils.py @@ -148,6 +148,7 @@ def searchType(libtype): Parameters: libtype (str): LibType to lookup (movie, show, season, episode, artist, album, track, collection) + Raises: :exc:`~plexapi.exceptions.NotFound`: Unknown libtype """ @@ -159,6 +160,24 @@ def searchType(libtype): raise NotFound('Unknown libtype: %s' % libtype) +def reverseSearchType(libtype): + """ Returns the string value of the library type. + + Parameters: + libtype (int): Integer value of the library type. + + Raises: + :exc:`~plexapi.exceptions.NotFound`: Unknown libtype + """ + if libtype in SEARCHTYPES: + return libtype + libtype = int(libtype) + for k, v in SEARCHTYPES.items(): + if libtype == v: + return k + raise NotFound('Unknown libtype: %s' % libtype) + + def threaded(callback, listargs): """ Returns the result of for each set of `*args` in listargs. Each call to is called concurrently in their own separate threads. diff --git a/lib/plexapi/video.py b/lib/plexapi/video.py index 609eaffc..090d9502 100644 --- a/lib/plexapi/video.py +++ b/lib/plexapi/video.py @@ -767,7 +767,9 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin, parentThumb (str): URL to season thumbnail image (/library/metadata//thumb/). parentTitle (str): Name of the season for the episode. parentYear (int): Year the season was released. + producers (List<:class:`~plexapi.media.Producer`>): List of producers objects. rating (float): Episode rating (7.9; 9.8; 8.1). + roles (List<:class:`~plexapi.media.Role`>): List of role objects. skipParent (bool): True if the show's seasons are set to hidden. viewOffset (int): View offset in milliseconds. writers (List<:class:`~plexapi.media.Writer`>): List of writers objects. @@ -809,7 +811,9 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin, self.parentThumb = data.attrib.get('parentThumb') self.parentTitle = data.attrib.get('parentTitle') self.parentYear = utils.cast(int, data.attrib.get('parentYear')) + self.producers = self.findItems(data, media.Producer) self.rating = utils.cast(float, data.attrib.get('rating')) + self.roles = self.findItems(data, media.Role) self.skipParent = utils.cast(bool, data.attrib.get('skipParent', '0')) self.viewOffset = utils.cast(int, data.attrib.get('viewOffset', 0)) self.writers = self.findItems(data, media.Writer) @@ -838,6 +842,11 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin, """ Returns a human friendly filename. """ return '%s.%s' % (self.grandparentTitle.replace(' ', '.'), self.seasonEpisode) + @property + def actors(self): + """ Alias to self.roles. """ + return self.roles + @property def locations(self): """ This does not exist in plex xml response but is added to have a common @@ -865,6 +874,11 @@ class Episode(Video, Playable, ArtMixin, PosterMixin, RatingMixin, """ Returns the s00e00 string containing the season and episode numbers. """ return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.episodeNumber).zfill(2)) + @property + def hasCommercialMarker(self): + """ Returns True if the episode has a commercial marker in the xml. """ + return any(marker.type == 'commercial' for marker in self.markers) + @property def hasIntroMarker(self): """ Returns True if the episode has an intro marker in the xml. """ From 5d05f6826a2ef64c98e910147210514c3e02f6d7 Mon Sep 17 00:00:00 2001 From: JonnyWong16 <9099342+JonnyWong16@users.noreply.github.com> Date: Sun, 15 Aug 2021 15:28:01 -0700 Subject: [PATCH 15/15] Add new episode exporter fields --- plexpy/exporter.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/plexpy/exporter.py b/plexpy/exporter.py index 9f5584c4..ab153ed8 100644 --- a/plexpy/exporter.py +++ b/plexpy/exporter.py @@ -566,6 +566,7 @@ class Export(object): 'guids': { 'id': None }, + 'hasCommercialMarker': None, 'hasIntroMarker': None, 'hasPreviewThumbnails': None, 'index': None, @@ -732,8 +733,18 @@ class Export(object): 'parentThumb': None, 'parentTitle': None, 'parentYear': None, + 'producers': { + 'id': None, + 'tag': None + }, 'rating': None, 'ratingKey': None, + 'roles': { + 'id': None, + 'tag': None, + 'role': None, + 'thumb': None + }, 'seasonEpisode': None, 'seasonNumber': None, 'summary': None, @@ -1318,10 +1329,11 @@ class Export(object): 'rating', 'audienceRating', 'audienceRatingImage', 'userRating', 'contentRating', 'summary', 'guid', 'duration', 'durationHuman', 'type', 'episodeNumber', 'seasonEpisode', 'parentTitle', 'parentRatingKey', 'parentGuid', 'parentYear', 'seasonNumber', - 'grandparentTitle', 'grandparentRatingKey', 'grandparentGuid', 'hasIntroMarker' + 'grandparentTitle', 'grandparentRatingKey', 'grandparentGuid', + 'hasCommercialMarker', 'hasIntroMarker' ], 2: [ - 'collections.tag', 'directors.tag', 'writers.tag', + 'collections.tag', 'directors.tag', 'writers.tag', 'producers.tag', 'roles.tag', 'roles.role', 'fields.name', 'fields.locked', 'guids.id', 'markers.type', 'markers.start', 'markers.end' ],