diff --git a/docs/developer_api.md b/docs/developer_api.md index babb1ee72..c292b2a20 100644 --- a/docs/developer_api.md +++ b/docs/developer_api.md @@ -1820,7 +1820,7 @@ Arguments : * `hash`: (selective, a hexadecimal SHA256 hash for the file) * `download`: (optional, boolean, default `false`) -Only use one of file_id or hash. As with metadata fetching, you may only use the hash argument if you have access to all files. If you are tag-restricted, you will have to use a file_id in the last search you ran. + Only use one of file_id or hash. As with metadata fetching, you may only use the hash argument if you have access to all files. If you are tag-restricted, you will have to use a file_id in the last search you ran. ``` title="Example request" /get_files/file?file_id=452158 @@ -1866,6 +1866,36 @@ Response: If you get a 'default' filetype thumbnail like the pdf or hydrus one, you will be pulling the defaults straight from the hydrus/static folder. They will most likely be 200x200 pixels. +### **GET `/get_files/render`** { id="get_files_render" } + +_Get an image file as rendered by Hydrus._ + +Restricted access: +: YES. Search for Files permission needed. Additional search permission limits may apply. + +Required Headers: n/a + +Arguments : +: + * `file_id`: (selective, numerical file id for the file) + * `hash`: (selective, a hexadecimal SHA256 hash for the file) + * `download`: (optional, boolean, default `false`) + + Only use one of file_id or hash. As with metadata fetching, you may only use the hash argument if you have access to all files. If you are tag-restricted, you will have to use a file_id in the last search you ran. + +The file you request must be a still image file that Hydrus can render (this includes PSD files). This request uses the client image cache. + +``` title="Example request" +/get_files/render?file_id=452158 +``` +``` title="Example request" +/get_files/render?hash=7f30c113810985b69014957c93bc25e8eb4cf3355dae36d8b9d011d8b0cf623a&download=true +``` + +Response: +: A PNG file of the image as would be rendered in the client. It will be converted to sRGB color if the file had a color profile but the rendered PNG will not have any color profile. + +By default, this will set the `Content-Disposition` header to `inline`, which causes a web browser to show the file. If you set `download=true`, it will set it to `attachment`, which triggers the browser to automatically download it (or open the 'save as' dialog) instead. ## Managing File Relationships diff --git a/hydrus/client/ClientRendering.py b/hydrus/client/ClientRendering.py index c9f67362e..35b289404 100644 --- a/hydrus/client/ClientRendering.py +++ b/hydrus/client/ClientRendering.py @@ -93,6 +93,11 @@ def __init__( self, media, this_is_for_metadata_alone = False ): self._this_is_for_metadata_alone = this_is_for_metadata_alone HG.client_controller.CallToThread( self._Initialise ) + + + def GetNumPyImage(self): + + return self._numpy_image def _GetNumPyImage( self, clip_rect: QC.QRect, target_resolution: QC.QSize ): diff --git a/hydrus/client/networking/ClientLocalServer.py b/hydrus/client/networking/ClientLocalServer.py index 5c2f1ca83..382f375df 100644 --- a/hydrus/client/networking/ClientLocalServer.py +++ b/hydrus/client/networking/ClientLocalServer.py @@ -91,6 +91,7 @@ def _InitRoot( self ): get_files.putChild( b'file_hashes', ClientLocalServerResources.HydrusResourceClientAPIRestrictedGetFilesFileHashes( self._service, self._client_requests_domain ) ) get_files.putChild( b'file', ClientLocalServerResources.HydrusResourceClientAPIRestrictedGetFilesGetFile( self._service, self._client_requests_domain ) ) get_files.putChild( b'thumbnail', ClientLocalServerResources.HydrusResourceClientAPIRestrictedGetFilesGetThumbnail( self._service, self._client_requests_domain ) ) + get_files.putChild( b'render', ClientLocalServerResources.HydrusResourceClientAPIRestrictedGetFilesGetRenderedFile( self._service, self._client_requests_domain) ) add_notes = NoResource() diff --git a/hydrus/client/networking/ClientLocalServerResources.py b/hydrus/client/networking/ClientLocalServerResources.py index b25549b8a..2b64f3203 100644 --- a/hydrus/client/networking/ClientLocalServerResources.py +++ b/hydrus/client/networking/ClientLocalServerResources.py @@ -39,6 +39,7 @@ from hydrus.client import ClientConstants as CC from hydrus.client import ClientLocation from hydrus.client import ClientThreading +from hydrus.client import ClientRendering from hydrus.client.importing import ClientImportFiles from hydrus.client.importing.options import FileImportOptions from hydrus.client.media import ClientMedia @@ -2812,6 +2813,68 @@ def _threadDoGETJob( self, request: HydrusServerRequest.HydrusRequest ): return response_context + +class HydrusResourceClientAPIRestrictedGetFilesGetRenderedFile( HydrusResourceClientAPIRestrictedGetFiles ): + + def _threadDoGETJob( self, request: HydrusServerRequest.HydrusRequest ): + + try: + + media_result: ClientMedia.MediaSingleton + + if 'file_id' in request.parsed_request_args: + + file_id = request.parsed_request_args.GetValue( 'file_id', int ) + + request.client_api_permissions.CheckPermissionToSeeFiles( ( file_id, ) ) + + ( media_result, ) = HG.client_controller.Read( 'media_results_from_ids', ( file_id, ) ) + + elif 'hash' in request.parsed_request_args: + + request.client_api_permissions.CheckCanSeeAllFiles() + + hash = request.parsed_request_args.GetValue( 'hash', bytes ) + + media_result = HG.client_controller.Read( 'media_result', hash ) + + else: + + raise HydrusExceptions.BadRequestException( 'Please include a file_id or hash parameter!' ) + + + except HydrusExceptions.DataMissing as e: + + raise HydrusExceptions.NotFoundException( 'One or more of those file identifiers was missing!' ) + + if not media_result.IsStaticImage(): + + raise HydrusExceptions.BadRequestException('Requested file is not an image!') + + + hash = media_result.GetHash() + + renderer: ClientRendering.ImageRenderer = HG.client_controller.GetCache( 'images' ).GetImageRenderer( media_result ) + + while not renderer.IsReady(): + + if request.disconnected: + + return + + time.sleep( 0.1 ) + + + numpy_image = renderer.GetNumPyImage() + + body = HydrusImageHandling.GeneratePNGBytesNumPy(numpy_image) + + is_attachment = request.parsed_request_args.GetValue( 'download', bool, default_value = False ) + + response_context = HydrusServerResources.ResponseContext( 200, mime = HC.IMAGE_PNG, body = body, is_attachment = is_attachment, max_age = 86400 * 365 ) + + return response_context + class HydrusResourceClientAPIRestrictedGetFilesFileHashes( HydrusResourceClientAPIRestrictedGetFiles ): diff --git a/hydrus/core/HydrusImageHandling.py b/hydrus/core/HydrusImageHandling.py index 9d42db37c..daed03b6a 100644 --- a/hydrus/core/HydrusImageHandling.py +++ b/hydrus/core/HydrusImageHandling.py @@ -540,6 +540,31 @@ def GenerateThumbnailBytesPIL( pil_image: PILImage.Image ) -> bytes: return thumbnail_bytes +def GeneratePNGBytesNumPy( numpy_image ) -> bytes: + + ( im_height, im_width, depth ) = numpy_image.shape + + ext = '.png' + + if depth == 4: + + convert = cv2.COLOR_RGBA2BGRA + + else: + + convert = cv2.COLOR_RGB2BGR + + numpy_image = cv2.cvtColor( numpy_image, convert ) + + ( result_success, result_byte_array ) = cv2.imencode( ext, numpy_image ) + + if result_success: + + return result_byte_array.tostring() + + else: + + raise HydrusExceptions.CantRenderWithCVException( 'Image failed to encode!' ) def GetEXIFDict( pil_image: PILImage.Image ) -> typing.Optional[ dict ]: diff --git a/hydrus/core/networking/HydrusServerRequest.py b/hydrus/core/networking/HydrusServerRequest.py index cfe401786..7c63f522f 100644 --- a/hydrus/core/networking/HydrusServerRequest.py +++ b/hydrus/core/networking/HydrusServerRequest.py @@ -18,6 +18,7 @@ def __init__( self, *args, **kwargs ): self.client_api_permissions = None self.disconnect_callables = [] self.preferred_mime = HC.APPLICATION_JSON + self.disconnected = False def IsGET( self ): diff --git a/hydrus/core/networking/HydrusServerResources.py b/hydrus/core/networking/HydrusServerResources.py index 79f9c1294..6b6073574 100644 --- a/hydrus/core/networking/HydrusServerResources.py +++ b/hydrus/core/networking/HydrusServerResources.py @@ -560,7 +560,7 @@ def _callbackRenderResponseContext( self, request: HydrusServerRequest.HydrusReq - response_context = request.hydrus_response_context + response_context: ResponseContext = request.hydrus_response_context if response_context.HasPath(): @@ -604,6 +604,13 @@ def _callbackRenderResponseContext( self, request: HydrusServerRequest.HydrusReq content_disposition_type = 'inline' + max_age = response_context.GetMaxAge() + + if max_age is not None: + + request.setHeader( 'Expires', time.strftime( '%a, %d %b %Y %H:%M:%S GMT', time.gmtime( time.time() + max_age ) ) ) + + request.setHeader( 'Cache-Control', 'max-age={}'.format( max_age ) ) if response_context.HasPath(): @@ -623,9 +630,6 @@ def _callbackRenderResponseContext( self, request: HydrusServerRequest.HydrusReq request.setHeader( 'Content-Disposition', str( content_disposition ) ) - request.setHeader( 'Expires', time.strftime( '%a, %d %b %Y %H:%M:%S GMT', time.gmtime( time.time() + 86400 * 365 ) ) ) - request.setHeader( 'Cache-Control', 'max-age={}'.format( 86400 * 365 ) ) - if len( offset_and_block_size_pairs ) <= 1: request.setHeader( 'Content-Type', str( content_type ) ) @@ -686,8 +690,7 @@ def _callbackRenderResponseContext( self, request: HydrusServerRequest.HydrusReq request.setHeader( 'Content-Type', content_type ) request.setHeader( 'Content-Length', str( content_length ) ) request.setHeader( 'Content-Disposition', content_disposition ) - request.setHeader( 'Cache-Control', 'max-age={}'.format( 4 ) ) # hydrus won't change its mind about dynamic data under 4 seconds even if you ask repeatedly - + request.write( body_bytes ) else: @@ -698,7 +701,7 @@ def _callbackRenderResponseContext( self, request: HydrusServerRequest.HydrusReq request.setHeader( 'Content-Length', str( content_length ) ) - + self._reportDataUsed( request, content_length ) self._reportRequestUsed( request ) @@ -782,6 +785,8 @@ def _DecompressionBombsOK( self, request: HydrusServerRequest.HydrusRequest ): def _errbackDisconnected( self, failure, request: HydrusServerRequest.HydrusRequest, request_deferred: defer.Deferred ): request_deferred.cancel() + + request.disconnected = True for c in request.disconnect_callables: @@ -1222,7 +1227,7 @@ def _threadDoGETJob( self, request: HydrusServerRequest.HydrusRequest ): class ResponseContext( object ): - def __init__( self, status_code, mime = HC.APPLICATION_JSON, body = None, path = None, cookies = None, is_attachment = False ): + def __init__( self, status_code, mime = HC.APPLICATION_JSON, body = None, path = None, cookies = None, is_attachment = False, max_age = None ): if body is None: @@ -1248,6 +1253,16 @@ def __init__( self, status_code, mime = HC.APPLICATION_JSON, body = None, path = if cookies is None: cookies = [] + + if max_age is None: + + if body is not None: + + max_age = 4 + + elif path is not None: + + max_age = 86400 * 365 self._status_code = status_code @@ -1256,6 +1271,7 @@ def __init__( self, status_code, mime = HC.APPLICATION_JSON, body = None, path = self._path = path self._cookies = cookies self._is_attachment = is_attachment + self._max_age = max_age def GetBodyBytes( self ): @@ -1270,6 +1286,8 @@ def GetMime( self ): return self._mime def GetPath( self ): return self._path def GetStatusCode( self ): return self._status_code + + def GetMaxAge( self ): return self._max_age def HasBody( self ): return self._body_bytes is not None