diff --git a/ayon_api/__init__.py b/ayon_api/__init__.py index 8b2450201..4ac3857ec 100644 --- a/ayon_api/__init__.py +++ b/ayon_api/__init__.py @@ -67,6 +67,7 @@ download_file, upload_file_from_stream, upload_file, + upload_reviewable, trigger_server_restart, query_graphql, get_graphql_schema, @@ -290,6 +291,7 @@ "download_file", "upload_file_from_stream", "upload_file", + "upload_reviewable", "trigger_server_restart", "query_graphql", "get_graphql_schema", diff --git a/ayon_api/_api.py b/ayon_api/_api.py index 4ce79e301..f0d6809b2 100644 --- a/ayon_api/_api.py +++ b/ayon_api/_api.py @@ -938,6 +938,29 @@ def upload_file(*args, **kwargs): return con.upload_file(*args, **kwargs) +def upload_reviewable(*args, **kwargs): + """Upload reviewable file to server. + + Args: + project_name (str): Project name. + version_id (str): Version id. + filepath (str): Reviewable file path to upload. + label (Optional[str]): Reviewable label. Filled automatically + server side with filename. + content_type (Optional[str]): MIME type of the file. + filename (Optional[str]): User as original filename. Filename from + 'filepath' is used when not filled. + progress (Optional[TransferProgress]): Progress. + headers (Optional[Dict[str, Any]]): Headers. + + Returns: + RestApiResponse: Server response. + + """ + con = get_server_api_connection() + return con.upload_reviewable(*args, **kwargs) + + def trigger_server_restart(): """Trigger server restart. diff --git a/ayon_api/server_api.py b/ayon_api/server_api.py index f8d4e07d7..ecd50ba89 100644 --- a/ayon_api/server_api.py +++ b/ayon_api/server_api.py @@ -90,6 +90,7 @@ get_default_settings_variant, get_default_site_id, NOT_SET, + get_media_mime_type, ) PatternType = type(re.compile("")) @@ -1931,6 +1932,77 @@ def upload_file( endpoint, stream, progress, request_type, **kwargs ) + def upload_reviewable( + self, + project_name, + version_id, + filepath, + label=None, + content_type=None, + filename=None, + progress=None, + headers=None, + **kwargs + ): + """Upload reviewable file to server. + + Args: + project_name (str): Project name. + version_id (str): Version id. + filepath (str): Reviewable file path to upload. + label (Optional[str]): Reviewable label. Filled automatically + server side with filename. + content_type (Optional[str]): MIME type of the file. + filename (Optional[str]): User as original filename. Filename from + 'filepath' is used when not filled. + progress (Optional[TransferProgress]): Progress. + headers (Optional[Dict[str, Any]]): Headers. + + Returns: + RestApiResponse: Server response. + + """ + if not content_type: + content_type = get_media_mime_type(filepath) + + if not content_type: + raise ValueError( + f"Could not determine MIME type of file '{filepath}'" + ) + + if headers is None: + headers = self.get_headers(content_type) + else: + # Make sure content-type is filled with file content type + content_type_key = next( + ( + key + for key in headers + if key.lower() == "content-type" + ), + "Content-Type" + ) + headers[content_type_key] = content_type + + # Fill original filename if not explicitly defined + if not filename: + filename = os.path.basename(filepath) + headers["x-file-name"] = filename + + query = f"?label={label}" if label else "" + endpoint = ( + f"/projects/{project_name}" + f"/versions/{version_id}/reviewables{query}" + ) + return self.upload_file( + endpoint, + filepath, + progress=progress, + headers=headers, + request_type=RequestTypes.post, + **kwargs + ) + def trigger_server_restart(self): """Trigger server restart. diff --git a/ayon_api/utils.py b/ayon_api/utils.py index b7098d787..aa9272b21 100644 --- a/ayon_api/utils.py +++ b/ayon_api/utils.py @@ -6,6 +6,7 @@ import platform import collections from urllib.parse import urlparse, urlencode +from typing import Optional import requests import unidecode @@ -704,3 +705,116 @@ def create_dependency_package_basename(platform_name=None): now_date = datetime.datetime.now() time_stamp = now_date.strftime("%y%m%d%H%M") return "ayon_{}_{}".format(time_stamp, platform_name) + + + +def _get_media_mime_type_from_ftyp(content): + if content[8:10] == b"qt" or content[8:12] == b"MSNV": + return "video/quicktime" + + if content[8:12] in (b"3g2a", b"3g2b", b"3g2c", b"KDDI"): + return "video/3gpp2" + + if content[8:12] in ( + b"isom", b"iso2", b"avc1", b"F4V", b"F4P", b"F4A", b"F4B", b"mmp4", + # These might be "video/mp4v" + b"mp41", b"mp42", + # Nero + b"NDSC", b"NDSH", b"NDSM", b"NDSP", b"NDSS", b"NDXC", b"NDXH", + b"NDXM", b"NDXP", b"NDXS", + ): + return "video/mp4" + + if content[8:12] in ( + b"3ge6", b"3ge7", b"3gg6", + b"3gp1", b"3gp2", b"3gp3", b"3gp4", b"3gp5", b"3gp6", b"3gs7", + ): + return "video/3gpp" + + if content[8:11] == b"JP2": + return "image/jp2" + + if content[8:11] == b"jpm": + return "image/jpm" + + if content[8:11] == b"jpx": + return "image/jpx" + + if content[8:12] in (b"M4V\x20", b"M4VH", b"M4VP"): + return "video/x-m4v" + + if content[8:12] in (b"mj2s", b"mjp2"): + return "video/mj2" + return None + + +def get_media_mime_type_for_content(content: bytes) -> Optional[str]: + content_len = len(content) + # Pre-validation (largest definition check) + # - hopefully there cannot be media defined in less than 12 bytes + if content_len < 12: + return None + + # FTYP + if content[4:8] == b"ftyp": + return _get_media_mime_type_from_ftyp(content) + + # BMP + if content[0:2] == b"BM": + return "image/bmp" + + # Tiff + if content[0:2] in (b"MM", b"II"): + return "tiff" + + # PNG + if content[0:4] == b"\211PNG": + return "image/png" + + # SVG + if b'xmlns="http://www.w3.org/2000/svg"' in content: + return "image/svg+xml" + + # JPEG, JFIF or Exif + if ( + content[0:4] == b"\xff\xd8\xff\xdb" + or content[6:10] in (b"JFIF", b"Exif") + ): + return "image/jpeg" + + # Webp + if content[0:4] == b"RIFF" and content[8:12] == b"WEBP": + return "image/webp" + + # Gif + if content[0:6] in (b"GIF87a", b"GIF89a"): + return "gif" + + # Adobe PhotoShop file (8B > Adobe, PS > PhotoShop) + if content[0:4] == b"8BPS": + return "image/vnd.adobe.photoshop" + + # Windows ICO > this might be wild guess as multiple files can start + # with this header + if content[0:4] == b"\x00\x00\x01\x00": + return "image/x-icon" + return None + + +def get_media_mime_type(filepath: str) -> Optional[str]: + """Determine Mime-Type of a file. + + Args: + filepath (str): Path to file. + + Returns: + Optional[str]: Mime type or None if is unknown mime type. + + """ + if not filepath or not os.path.exists(filepath): + return None + + with open(filepath, "rb") as stream: + content = stream.read() + + return get_media_mime_type_for_content(content)