diff --git a/data/interfaces/default/export_modal.html b/data/interfaces/default/export_modal.html index bdda847a..b91d31f1 100644 --- a/data/interfaces/default/export_modal.html +++ b/data/interfaces/default/export_modal.html @@ -226,7 +226,7 @@ DOCUMENTATION :: END getExportFields(); $('#export_file_format').on('change', function() { - if ($(this).val() === 'm3u8') { + if ($(this).val() === 'm3u') { $('#export_metadata_level').prop('disabled', true); $('#export_media_info_level').prop('disabled', true); $("#export_thumb_level").prop('disabled', true); diff --git a/lib/cloudinary/__init__.py b/lib/cloudinary/__init__.py index 5e315049..da7ca9ae 100644 --- a/lib/cloudinary/__init__.py +++ b/lib/cloudinary/__init__.py @@ -1,14 +1,14 @@ from __future__ import absolute_import import abc -from copy import deepcopy -import hashlib -import os -import re import logging import numbers -import certifi +import os +import re +from copy import deepcopy from math import ceil + +import certifi from six import python_2_unicode_compatible, add_metaclass logger = logging.getLogger("Cloudinary") @@ -23,7 +23,7 @@ from cloudinary.cache import responsive_breakpoints_cache from cloudinary.http_client import HttpClient from cloudinary.compat import urlparse, parse_qs -from platform import python_version +from platform import python_version, platform CERT_KWARGS = { 'cert_reqs': 'CERT_REQUIRED', @@ -38,15 +38,17 @@ CL_BLANK = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAA URI_SCHEME = "cloudinary" API_VERSION = "v1_1" -VERSION = "1.29.0" +VERSION = "1.30.0" -USER_AGENT = "CloudinaryPython/{} (Python {})".format(VERSION, python_version()) +_USER_PLATFORM_DETAILS = "; ".join((platform(), "Python {}".format(python_version()))) + +USER_AGENT = "CloudinaryPython/{} ({})".format(VERSION, _USER_PLATFORM_DETAILS) """ :const: USER_AGENT """ USER_PLATFORM = "" """ -Additional information to be passed with the USER_AGENT, e.g. "CloudinaryMagento/1.0.1". -This value is set in platform-specific implementations that use cloudinary_php. +Additional information to be passed with the USER_AGENT, e.g. "CloudinaryCLI/1.2.3". +This value is set in platform-specific implementations that use pycloudinary. The format of the value should be /Version[ (comment)]. @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.43 diff --git a/lib/cloudinary/api.py b/lib/cloudinary/api.py index 58002254..73d18fa1 100644 --- a/lib/cloudinary/api.py +++ b/lib/cloudinary/api.py @@ -93,6 +93,23 @@ def resources_by_ids(public_ids, **options): return call_api("get", uri, params, **options) +def resources_by_asset_folder(asset_folder, **options): + """ + Returns the details of the resources (assets) under a specified asset_folder. + + :param asset_folder: The Asset Folder of the asset + :type asset_folder: string + :param options: Additional options + :type options: dict, optional + :return: Resources (assets) of a specific asset_folder + :rtype: Response + """ + uri = ["resources", "by_asset_folder"] + params = only(options, "max_results", "tags", "moderations", "context", "next_cursor") + params["asset_folder"] = asset_folder + return call_api("get", uri, params, **options) + + def resources_by_asset_ids(asset_ids, **options): """Retrieves the resources (assets) indicated in the asset IDs. This method does not return deleted assets even if they have been backed up. @@ -131,7 +148,7 @@ def resources_by_context(key, value=None, **options): resource_type = options.pop("resource_type", "image") uri = ["resources", resource_type, "context"] params = only(options, "next_cursor", "max_results", "tags", - "context", "moderations", "direction", "metadata") + "context", "moderations", "direction", "metadata") params["key"] = key if value is not None: params["value"] = value @@ -194,10 +211,18 @@ def update(public_id, **options): options.get("custom_coordinates")) if "context" in options: params["context"] = utils.encode_context(options.get("context")) + if "metadata" in options: + params["metadata"] = utils.encode_context(options.get("metadata")) if "auto_tagging" in options: params["auto_tagging"] = str(options.get("auto_tagging")) if "access_control" in options: params["access_control"] = utils.json_encode(utils.build_list_of_dicts(options.get("access_control"))) + if "asset_folder" in options: + params["asset_folder"] = options.get("asset_folder") + if "display_name" in options: + params["display_name"] = options.get("display_name") + if "unique_display_name" in options: + params["unique_display_name"] = options.get("unique_display_name") return call_api("post", uri, params, **options) diff --git a/lib/cloudinary/auth_token.py b/lib/cloudinary/auth_token.py index 3aaf3844..4f6c1fe1 100644 --- a/lib/cloudinary/auth_token.py +++ b/lib/cloudinary/auth_token.py @@ -30,7 +30,9 @@ def generate(url=None, acl=None, start_time=None, duration=None, token_parts.append("st=%d" % start_time) token_parts.append("exp=%d" % expiration) if acl is not None: - token_parts.append("acl=%s" % _escape_to_lower(acl)) + acl_list = acl if type(acl) is list else [acl] + acl_list = [_escape_to_lower(a) for a in acl_list] + token_parts.append("acl=%s" % "!".join(acl_list)) to_sign = list(token_parts) if url is not None and acl is None: to_sign.append("url=%s" % _escape_to_lower(url)) diff --git a/lib/cloudinary/models.py b/lib/cloudinary/models.py index 5ccb133d..1240150e 100644 --- a/lib/cloudinary/models.py +++ b/lib/cloudinary/models.py @@ -106,7 +106,7 @@ class CloudinaryField(models.Field): value = super(CloudinaryField, self).pre_save(model_instance, add) if isinstance(value, UploadedFile): options = {"type": self.type, "resource_type": self.resource_type} - options.update(self.options) + options.update({key: val(model_instance) if callable(val) else val for key, val in self.options.items()}) if hasattr(value, 'seekable') and value.seekable(): value.seek(0) instance_value = uploader.upload_resource(value, **options) diff --git a/lib/cloudinary/provisioning/account.py b/lib/cloudinary/provisioning/account.py index 6607f101..414c2727 100644 --- a/lib/cloudinary/provisioning/account.py +++ b/lib/cloudinary/provisioning/account.py @@ -65,7 +65,7 @@ def create_sub_account(name, cloud_name=None, custom_attributes=None, enabled=No "cloud_name": cloud_name, "custom_attributes": custom_attributes, "enabled": enabled, - "base_account": base_account} + "base_sub_account_id": base_account} return _call_account_api("POST", uri, params=params, **options) diff --git a/lib/cloudinary/static/html/cloudinary_cors.html b/lib/cloudinary/static/cloudinary/html/cloudinary_cors.html similarity index 100% rename from lib/cloudinary/static/html/cloudinary_cors.html rename to lib/cloudinary/static/cloudinary/html/cloudinary_cors.html diff --git a/lib/cloudinary/static/js/canvas-to-blob.min.js b/lib/cloudinary/static/cloudinary/js/canvas-to-blob.min.js similarity index 100% rename from lib/cloudinary/static/js/canvas-to-blob.min.js rename to lib/cloudinary/static/cloudinary/js/canvas-to-blob.min.js diff --git a/lib/cloudinary/static/js/jquery.cloudinary.js b/lib/cloudinary/static/cloudinary/js/jquery.cloudinary.js similarity index 99% rename from lib/cloudinary/static/js/jquery.cloudinary.js rename to lib/cloudinary/static/cloudinary/js/jquery.cloudinary.js index 998054b9..7b41a0f2 100644 --- a/lib/cloudinary/static/js/jquery.cloudinary.js +++ b/lib/cloudinary/static/cloudinary/js/jquery.cloudinary.js @@ -1093,11 +1093,11 @@ var slice = [].slice, * * If the parameter is an object, * @param {(string|Object)} param - the video codec as either a String or a Hash - * @return {string} the video codec string in the format codec:profile:level + * @return {string} the video codec string in the format codec:profile:level:b_frames * @example - * vc_[ :profile : [level]] + * vc_[ :profile : [level : [b_frames]]] * or - { codec: 'h264', profile: 'basic', level: '3.1' } + { codec: 'h264', profile: 'basic', level: '3.1', b_frames: false } * @ignore */ @@ -1112,6 +1112,9 @@ var slice = [].slice, video += ":" + param['profile']; if ('level' in param) { video += ":" + param['level']; + if ('b_frames' in param && param['b_frames'] === false) { + video += ":bframes_no"; + } } } } diff --git a/lib/cloudinary/static/js/jquery.fileupload-image.js b/lib/cloudinary/static/cloudinary/js/jquery.fileupload-image.js similarity index 100% rename from lib/cloudinary/static/js/jquery.fileupload-image.js rename to lib/cloudinary/static/cloudinary/js/jquery.fileupload-image.js diff --git a/lib/cloudinary/static/js/jquery.fileupload-process.js b/lib/cloudinary/static/cloudinary/js/jquery.fileupload-process.js similarity index 100% rename from lib/cloudinary/static/js/jquery.fileupload-process.js rename to lib/cloudinary/static/cloudinary/js/jquery.fileupload-process.js diff --git a/lib/cloudinary/static/js/jquery.fileupload-validate.js b/lib/cloudinary/static/cloudinary/js/jquery.fileupload-validate.js similarity index 100% rename from lib/cloudinary/static/js/jquery.fileupload-validate.js rename to lib/cloudinary/static/cloudinary/js/jquery.fileupload-validate.js diff --git a/lib/cloudinary/static/js/jquery.fileupload.js b/lib/cloudinary/static/cloudinary/js/jquery.fileupload.js similarity index 100% rename from lib/cloudinary/static/js/jquery.fileupload.js rename to lib/cloudinary/static/cloudinary/js/jquery.fileupload.js diff --git a/lib/cloudinary/static/js/jquery.iframe-transport.js b/lib/cloudinary/static/cloudinary/js/jquery.iframe-transport.js similarity index 100% rename from lib/cloudinary/static/js/jquery.iframe-transport.js rename to lib/cloudinary/static/cloudinary/js/jquery.iframe-transport.js diff --git a/lib/cloudinary/static/js/jquery.ui.widget.js b/lib/cloudinary/static/cloudinary/js/jquery.ui.widget.js similarity index 100% rename from lib/cloudinary/static/js/jquery.ui.widget.js rename to lib/cloudinary/static/cloudinary/js/jquery.ui.widget.js diff --git a/lib/cloudinary/static/js/load-image.all.min.js b/lib/cloudinary/static/cloudinary/js/load-image.all.min.js similarity index 100% rename from lib/cloudinary/static/js/load-image.all.min.js rename to lib/cloudinary/static/cloudinary/js/load-image.all.min.js diff --git a/lib/cloudinary/templates/cloudinary_includes.html b/lib/cloudinary/templates/cloudinary_includes.html index 82361c33..908c41d2 100644 --- a/lib/cloudinary/templates/cloudinary_includes.html +++ b/lib/cloudinary/templates/cloudinary_includes.html @@ -1,14 +1,14 @@ {% load static %} - - - - + + + + {% if processing %} - - - - - + + + + + {% endif %} diff --git a/lib/cloudinary/utils.py b/lib/cloudinary/utils.py index 1fade15b..f7f94512 100644 --- a/lib/cloudinary/utils.py +++ b/lib/cloudinary/utils.py @@ -94,6 +94,8 @@ __SIMPLE_UPLOAD_PARAMS = [ "proxy", "folder", "asset_folder", + "use_asset_folder_as_public_id_prefix", + "unique_display_name", "overwrite", "moderation", "raw_convert", diff --git a/lib/tokenize_rt.py b/lib/tokenize_rt.py index b225a9f0..f0922c16 100644 --- a/lib/tokenize_rt.py +++ b/lib/tokenize_rt.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import argparse import io import keyword @@ -6,20 +8,17 @@ import sys import tokenize from typing import Generator from typing import Iterable -from typing import List from typing import NamedTuple -from typing import Optional from typing import Pattern from typing import Sequence -from typing import Tuple # this is a performance hack. see https://bugs.python.org/issue43014 -if ( +if ( # pragma: no branch sys.version_info < (3, 10) and callable(getattr(tokenize, '_compile', None)) -): # pragma: no cover ( Offset: @@ -43,11 +42,10 @@ class Token(NamedTuple): _string_re = re.compile('^([^\'"]*)(.*)$', re.DOTALL) -_string_prefixes = frozenset('bfru') _escaped_nl_re = re.compile(r'\\(\n|\r\n|\r)') -def _re_partition(regex: Pattern[str], s: str) -> Tuple[str, str, str]: +def _re_partition(regex: Pattern[str], s: str) -> tuple[str, str, str]: match = regex.search(s) if match: return s[:match.start()], s[slice(*match.span())], s[match.end():] @@ -55,7 +53,7 @@ def _re_partition(regex: Pattern[str], s: str) -> Tuple[str, str, str]: return (s, '', '') -def src_to_tokens(src: str) -> List[Token]: +def src_to_tokens(src: str) -> list[Token]: tokenize_target = io.StringIO(src) lines = ('',) + tuple(tokenize_target) @@ -98,33 +96,7 @@ def src_to_tokens(src: str) -> List[Token]: end_offset += len(newtok.encode()) tok_name = tokenize.tok_name[tok_type] - # when a string prefix is not recognized, the tokenizer produces a - # NAME token followed by a STRING token - if ( - tok_name == 'STRING' and - tokens and - tokens[-1].name == 'NAME' and - frozenset(tokens[-1].src.lower()) <= _string_prefixes - ): - newsrc = tokens[-1].src + tok_text - tokens[-1] = tokens[-1]._replace(src=newsrc, name=tok_name) - # produce octal literals as a single token in python 3 as well - elif ( - tok_name == 'NUMBER' and - tokens and - tokens[-1].name == 'NUMBER' - ): - tokens[-1] = tokens[-1]._replace(src=tokens[-1].src + tok_text) - # produce long literals as a single token in python 3 as well - elif ( - tok_name == 'NAME' and - tok_text.lower() == 'l' and - tokens and - tokens[-1].name == 'NUMBER' - ): - tokens[-1] = tokens[-1]._replace(src=tokens[-1].src + tok_text) - else: - tokens.append(Token(tok_name, tok_text, sline, end_offset)) + tokens.append(Token(tok_name, tok_text, sline, end_offset)) last_line, last_col = eline, ecol if sline != eline: end_offset = len(lines[last_line][:last_col].encode()) @@ -140,19 +112,19 @@ def tokens_to_src(tokens: Iterable[Token]) -> str: def reversed_enumerate( tokens: Sequence[Token], -) -> Generator[Tuple[int, Token], None, None]: +) -> Generator[tuple[int, Token], None, None]: for i in reversed(range(len(tokens))): yield i, tokens[i] -def parse_string_literal(src: str) -> Tuple[str, str]: +def parse_string_literal(src: str) -> tuple[str, str]: """parse a string literal's source into (prefix, string)""" match = _string_re.match(src) assert match is not None return match.group(1), match.group(2) -def rfind_string_parts(tokens: Sequence[Token], i: int) -> Tuple[int, ...]: +def rfind_string_parts(tokens: Sequence[Token], i: int) -> tuple[int, ...]: """find the indicies of the string parts of a (joined) string literal - `i` should start at the end of the string literal @@ -195,7 +167,7 @@ def rfind_string_parts(tokens: Sequence[Token], i: int) -> Tuple[int, ...]: return tuple(reversed(ret)) -def main(argv: Optional[Sequence[str]] = None) -> int: +def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument('filename') args = parser.parse_args(argv) @@ -210,4 +182,4 @@ def main(argv: Optional[Sequence[str]] = None) -> int: if __name__ == '__main__': - exit(main()) + raise SystemExit(main()) diff --git a/package/requirements-package.txt b/package/requirements-package.txt index ce53967b..7dc0227b 100644 --- a/package/requirements-package.txt +++ b/package/requirements-package.txt @@ -1,11 +1,11 @@ -apscheduler==3.9.1 +apscheduler==3.9.1.post1 importlib-metadata==5.0.0 importlib-resources==5.10.0 pyinstaller==5.6.2 -pyopenssl==22.0.0 +pyopenssl==22.1.0 pycryptodomex==3.15.0 -pyobjc-framework-Cocoa==8.5; platform_system == "Darwin" +pyobjc-framework-Cocoa==9.0; platform_system == "Darwin" pyobjc-core==9.0; platform_system == "Darwin" -pywin32==304; platform_system == "Windows" +pywin32==305; platform_system == "Windows" diff --git a/plexpy/config.py b/plexpy/config.py index 724e1a08..544cf79a 100644 --- a/plexpy/config.py +++ b/plexpy/config.py @@ -126,6 +126,7 @@ _CONFIG_DEFINITIONS = { 'HTTPS_KEY': (str, 'General', ''), 'HTTPS_DOMAIN': (str, 'General', 'localhost'), 'HTTPS_IP': (str, 'General', '127.0.0.1'), + 'HTTPS_MIN_TLS_VERSION': (str, 'Advanced', 'TLSv1.2'), 'HTTP_BASIC_AUTH': (int, 'General', 0), 'HTTP_ENVIRONMENT': (str, 'General', 'production'), 'HTTP_HASH_PASSWORD': (int, 'General', 1), diff --git a/plexpy/exporter.py b/plexpy/exporter.py index 09c0c6b0..f9e4e893 100644 --- a/plexpy/exporter.py +++ b/plexpy/exporter.py @@ -102,7 +102,7 @@ class Export(object): METADATA_LEVELS = (0, 1, 2, 3, 9) MEDIA_INFO_LEVELS = (0, 1, 2, 3, 9) IMAGE_LEVELS = (0, 1, 2, 9) - FILE_FORMATS = ('csv', 'json', 'xml', 'm3u8') + FILE_FORMATS = ('csv', 'json', 'xml', 'm3u') EXPORT_TYPES = ('all', 'collection', 'playlist') def __init__(self, section_id=None, user_id=None, rating_key=None, file_format='csv', @@ -141,8 +141,8 @@ class Export(object): self.exported_items = 0 self.success = False - # Reset export options for m3u8 - if self.file_format == 'm3u8': + # Reset export options for m3u + if self.file_format == 'm3u': self.metadata_level = 1 self.media_info_level = 1 self.thumb_level = 0 @@ -1960,10 +1960,10 @@ class Export(object): with open(filepath, 'w', encoding='utf-8') as outfile: outfile.write(xml_data) - elif self.file_format == 'm3u8': - m3u8_data = self.data_to_m3u8(result, obj) + elif self.file_format == 'm3u': + m3u_data = self.data_to_m3u(result, obj) with open(filepath, 'w', encoding='utf-8') as outfile: - outfile.write(m3u8_data) + outfile.write(m3u_data) self.file_size += os.path.getsize(filepath) @@ -2119,36 +2119,36 @@ class Export(object): return helpers.dict_to_xml(xml_metadata, root_node='export', indent=4) - def data_to_m3u8(self, data, obj): - items = self._get_m3u8_items(data) + def data_to_m3u(self, data, obj): + items = self._get_m3u_items(data) - m3u8_metadata = {'title': obj.title, 'type': obj.media_type} + m3u_metadata = {'title': obj.title, 'type': obj.media_type} if obj.rating_key: - m3u8_metadata['ratingKey'] = obj.rating_key + m3u_metadata['ratingKey'] = obj.rating_key if obj.user_id: - m3u8_metadata['userID'] = obj.user_id + m3u_metadata['userID'] = obj.user_id if obj.section_id: - m3u8_metadata['sectionID'] = obj.section_id + m3u_metadata['sectionID'] = obj.section_id - m3u8 = '#EXTM3U\n' - m3u8 += '# Playlist: {title}\n# {metadata}\n\n'.format(title=obj.title, metadata=json.dumps(m3u8_metadata)) - m3u8_item_template = '# {metadata}\n#EXTINF:{duration},{title}\n{location}\n' - m3u8_items = [] + m3u = '#EXTM3U\n' + m3u += '# Playlist: {title}\n# {metadata}\n\n'.format(title=obj.title, metadata=json.dumps(m3u_metadata)) + m3u_item_template = '# {metadata}\n#EXTINF:{duration},{title}\n{location}\n' + m3u_items = [] for item in items: - m3u8_values = { + m3u_values = { 'duration': item.pop('duration'), 'title': item.pop('title'), 'location': item.pop('location'), 'metadata': json.dumps(item) } - m3u8_items.append(m3u8_item_template.format(**m3u8_values)) + m3u_items.append(m3u_item_template.format(**m3u_values)) - m3u8 = m3u8 + '\n'.join(m3u8_items) + m3u = m3u + '\n'.join(m3u_items) - return m3u8 + return m3u - def _get_m3u8_items(self, data): + def _get_m3u_items(self, data): items = [] for d in data: @@ -2167,7 +2167,7 @@ class Export(object): items.append(metadata) for child_media_type in self.CHILD_MEDIA_TYPES[d['type']]: - child_locations = self._get_m3u8_items(d[self.PLURAL_MEDIA_TYPES[child_media_type]]) + child_locations = self._get_m3u_items(d[self.PLURAL_MEDIA_TYPES[child_media_type]]) items.extend(child_locations) return items diff --git a/plexpy/webserve.py b/plexpy/webserve.py index 040d61c7..76c44243 100644 --- a/plexpy/webserve.py +++ b/plexpy/webserve.py @@ -7003,7 +7003,7 @@ class WebInterface(object): rating_key (int): The rating key of the media item to export Optional parameters: - file_format (str): csv (default), json, xml, or m3u8 + file_format (str): csv (default), json, xml, or m3u metadata_level (int): The level of metadata to export (default 1) media_info_level (int): The level of media info to export (default 1) thumb_level (int): The level of poster/cover images to export (default 0) @@ -7084,7 +7084,7 @@ class WebInterface(object): elif result['file_format'] == 'xml': return serve_file(filepath, name=result['filename'], content_type='application/xml;charset=UTF-8') - elif result['file_format'] == 'm3u8': + elif result['file_format'] == 'm3u': return serve_file(filepath, name=result['filename'], content_type='text/plain;charset=UTF-8') else: diff --git a/plexpy/webstart.py b/plexpy/webstart.py index 3b8a1a90..964a8b75 100644 --- a/plexpy/webstart.py +++ b/plexpy/webstart.py @@ -46,6 +46,7 @@ def start(): 'https_cert': plexpy.CONFIG.HTTPS_CERT, 'https_cert_chain': plexpy.CONFIG.HTTPS_CERT_CHAIN, 'https_key': plexpy.CONFIG.HTTPS_KEY, + 'https_min_tls_version': plexpy.CONFIG.HTTPS_MIN_TLS_VERSION, 'http_username': plexpy.CONFIG.HTTP_USERNAME, 'http_password': plexpy.CONFIG.HTTP_PASSWORD, 'http_basic_auth': plexpy.CONFIG.HTTP_BASIC_AUTH @@ -106,7 +107,11 @@ def initialize(options): purpose=ssl.Purpose.CLIENT_AUTH, cafile=https_cert_chain ) - context.minimum_version = ssl.TLSVersion.TLSv1_2 + + min_tls_version = options['https_min_tls_version'].replace('.', '_') + context.minimum_version = getattr(ssl.TLSVersion, min_tls_version, ssl.TLSVersion.TLSv1_2) + logger.debug("Tautulli WebStart :: Web server minimum TLS version set to %s.", context.minimum_version.name) + context.load_cert_chain(https_cert, https_key) options_dict['server.ssl_context'] = context diff --git a/requirements.txt b/requirements.txt index 2b25a927..7b597123 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ appdirs==1.4.4 -apscheduler==3.9.1 +apscheduler==3.9.1.post1 arrow==1.2.3 backports.csv==1.0.7 backports.functools-lru-cache==1.6.4 @@ -9,7 +9,7 @@ bleach==5.0.1 certifi==2022.9.24 cheroot==8.6.0 cherrypy==18.8.0 -cloudinary==1.29.0 +cloudinary==1.30.0 distro==1.8.0 dnspython==2.2.1 facebook-sdk==3.1.0 @@ -42,7 +42,7 @@ simplejson==3.17.6 six==1.16.0 soupsieve==2.3.2.post1 tempora==5.0.2 -tokenize-rt==4.2.1 +tokenize-rt==5.0.0 tzdata==2022.6 tzlocal==4.2 urllib3==1.26.12