22
33import json
44import logging
5- import math
65import time
76import uuid
87from fractions import Fraction
98from http import HTTPStatus
9+ from io import BytesIO
1010from pathlib import Path
11- from typing import TYPE_CHECKING
11+ from typing import TYPE_CHECKING , TypeAlias
1212
1313import exifread
1414import httpx
1818logger = logging .getLogger ("bma_client" )
1919
2020if TYPE_CHECKING :
21- from io import BytesIO
22-
2321 from django .http import HttpRequest
2422
23+ ImageConversionJobResult : TypeAlias = tuple [Image .Image , Image .Exif ]
24+ ExifExtractionJobResult : TypeAlias = dict [str , dict [str , str ]]
25+ JobResult : TypeAlias = ImageConversionJobResult | ExifExtractionJobResult
26+
2527# maybe these should come from server settings
2628SKIP_EXIF_TAGS = ["JPEGThumbnail" , "TIFFThumbnail" , "Filename" ]
2729
@@ -91,20 +93,20 @@ def get_jobs(self, job_filter: str = "?limit=0") -> list[dict[str, str]]:
9193 """Get a filtered list of the jobs this user has access to."""
9294 r = self .client .get (self .base_url + f"/api/v1/json/jobs/{ job_filter } " ).raise_for_status ()
9395 response = r .json ()["bma_response" ]
94- logger .debug (f"Returning { len (response )} jobs" )
96+ logger .debug (f"Returning { len (response )} jobs with filter { job_filter } " )
9597 return response
9698
9799 def get_file_info (self , file_uuid : uuid .UUID ) -> dict [str , str ]:
98100 """Get metadata for a file."""
99101 r = self .client .get (self .base_url + f"/api/v1/json/files/{ file_uuid } /" ).raise_for_status ()
100102 return r .json ()["bma_response" ]
101103
102- def download (self , file_uuid : uuid .UUID ) -> bytes :
104+ def download (self , file_uuid : uuid .UUID ) -> dict [ str , str ] :
103105 """Download a file from BMA."""
104106 info = self .get_file_info (file_uuid = file_uuid )
105107 path = self .path / info ["filename" ]
106108 if not path .exists ():
107- url = self .base_url + info ["links" ]["downloads" ]["original" ]
109+ url = self .base_url + info ["links" ]["downloads" ]["original" ] # type: ignore[index]
108110 logger .debug (f"Downloading file { url } ..." )
109111 r = self .client .get (url ).raise_for_status ()
110112 logger .debug (f"Done downloading { len (r .content )} bytes, saving to { path } " )
@@ -119,39 +121,44 @@ def get_job_assignment(self, file_uuid: uuid.UUID | None = None) -> list[dict[st
119121 url += f"?file_uuid={ file_uuid } "
120122 data = {"client_uuid" : self .uuid }
121123 try :
122- r = self .client .post (url , data = json . dumps ( data ) ).raise_for_status ()
124+ r = self .client .post (url , json = data ).raise_for_status ()
123125 response = r .json ()["bma_response" ]
124126 except httpx .HTTPStatusError as e :
125127 if e .response .status_code == HTTPStatus .NOT_FOUND :
126128 response = []
127129 else :
128130 raise
129- logger .debug (f"Returning { len (response )} jobs" )
131+ logger .debug (f"Returning { len (response )} assigned jobs" )
130132 return response
131133
132134 def upload_file (self , path : Path , attribution : str , file_license : str ) -> dict [str , dict [str , str ]]:
133135 """Upload a file."""
134- # is this an image?
135- extension = path .suffix [1 :]
136- for extensions in self .settings ["filetypes" ]["images" ].values ():
137- if extension .lower () in extensions :
138- # this file has the extension of a supported image
139- logger .debug (f"Extension { extension } is supported..." )
136+ # get mimetype
137+ with path .open ("rb" ) as fh :
138+ mimetype = magic .from_buffer (fh .read (2048 ), mime = True )
139+
140+ # find filetype (image, video, audio or document) from mimetype
141+ for filetype in self .settings ["filetypes" ]:
142+ if mimetype in self .settings ["filetypes" ][filetype ]:
140143 break
141144 else :
142- # file type not supported
143- raise ValueError (f"{ path .suffix } " )
144-
145- # get image dimensions
146- with Image .open (path ) as image :
147- rotated = ImageOps .exif_transpose (image ) # creates a copy with rotation normalised
148- logger .debug (
149- f"Image has exif rotation info, using post-rotate size { rotated .size } instead of raw size { image .size } "
145+ # unsupported mimetype
146+ logger .error (
147+ f"Mimetype { mimetype } is not supported by this BMA server. Supported types { self .settings ['filetypes' ]} "
150148 )
151- width , height = rotated .size
152-
153- with path .open ("rb" ) as fh :
154- mimetype = magic .from_buffer (fh .read (2048 ), mime = True )
149+ raise ValueError (mimetype )
150+
151+ if filetype == "image" :
152+ # get image dimensions
153+ with Image .open (path ) as image :
154+ rotated = ImageOps .exif_transpose (image ) # creates a copy with rotation normalised
155+ if rotated is None :
156+ raise ValueError ("Rotation" )
157+ logger .debug (
158+ f"Image has exif rotation info, using post-rotate size { rotated .size } "
159+ f"instead of raw size { image .size } "
160+ )
161+ width , height = rotated .size
155162
156163 # open file
157164 with path .open ("rb" ) as fh :
@@ -160,10 +167,15 @@ def upload_file(self, path: Path, attribution: str, file_license: str) -> dict[s
160167 data = {
161168 "attribution" : attribution ,
162169 "license" : file_license ,
163- "width" : width ,
164- "height" : height ,
165170 "mimetype" : mimetype ,
166171 }
172+ if filetype == "image" :
173+ data .update (
174+ {
175+ "width" : width ,
176+ "height" : height ,
177+ }
178+ )
167179 # doit
168180 r = self .client .post (
169181 self .base_url + "/api/v1/json/files/upload/" ,
@@ -172,18 +184,47 @@ def upload_file(self, path: Path, attribution: str, file_license: str) -> dict[s
172184 )
173185 return r .json ()
174186
175- def handle_job (self , job : dict [str , str ], orig : Path ) -> tuple [Image .Image , Image .Exif ]:
176- """Do the thing and return the result."""
187+ def handle_job (self , job : dict [str , str ], orig : Path ) -> None :
188+ """Do the thing and upload the result."""
189+ result : JobResult
190+ # get the result of the job
177191 if job ["job_type" ] == "ImageConversionJob" :
178- return self .handle_image_conversion_job (job = job , orig = orig )
179- if job ["job_type" ] == "ImageExifExtractionJob" :
180- return self .get_exif (orig )
181- logger .error (f"Unsupported job type { job ['job_type' ]} " )
182- return None
192+ result = self .handle_image_conversion_job (job = job , orig = orig )
193+ filename = job ["job_uuid" ] + "." + job ["filetype" ].lower ()
194+ elif job ["job_type" ] == "ImageExifExtractionJob" :
195+ result = self .get_exif (fname = orig )
196+ filename = "exif.json"
197+ else :
198+ logger .error (f"Unsupported job type { job ['job_type' ]} " )
199+
200+ self .write_and_upload_result (job = job , result = result , filename = filename )
201+
202+ def write_and_upload_result (self , job : dict [str , str ], result : JobResult , filename : str ) -> None :
203+ """Encode and write the job result to a buffer, then upload."""
204+ with BytesIO () as buf :
205+ if job ["job_type" ] == "ImageConversionJob" :
206+ image , exif = result
207+ if not isinstance (image , Image .Image ) or not isinstance (exif , Image .Exif ):
208+ raise ValueError ("Fuck" )
209+ # apply format specific encoding options
210+ kwargs = {}
211+ if job ["mimetype" ] in self .settings ["encoding" ]["images" ]:
212+ # this format has custom encoding options, like quality/lossless, apply them
213+ kwargs .update (self .settings ["encoding" ]["images" ][job ["mimetype" ]])
214+ logger .debug (f"Format { job ['mimetype' ]} has custom encoding settings, kwargs is now: { kwargs } " )
215+ else :
216+ logger .debug (f"No custom settings for format { job ['mimetype' ]} " )
217+ image .save (buf , format = job ["filetype" ], exif = exif , ** kwargs )
218+ elif job ["job_type" ] == "ImageExifExtractionJob" :
219+ logger .debug (f"Got exif data { result } " )
220+ buf .write (json .dumps (result ).encode ())
221+ else :
222+ logger .error ("Unsupported job type" )
223+ raise RuntimeError (job ["job_type" ])
224+ self .upload_job_result (job_uuid = uuid .UUID (job ["job_uuid" ]), buf = buf , filename = filename )
183225
184- def handle_image_conversion_job (self , job : dict [str , str ], orig : Path ) -> tuple [ Image . Image , Image . Exif ] :
226+ def handle_image_conversion_job (self , job : dict [str , str ], orig : Path ) -> ImageConversionJobResult :
185227 """Handle image conversion job."""
186- # load original image
187228 start = time .time ()
188229 logger .debug (f"Opening original image { orig } ..." )
189230 image = Image .open (orig )
@@ -193,29 +234,33 @@ def handle_image_conversion_job(self, job: dict[str, str], orig: Path) -> tuple[
193234
194235 logger .debug ("Rotating image (if needed)..." )
195236 start = time .time ()
196- image = ImageOps .exif_transpose (image ) # creates a copy with rotation normalised
237+ ImageOps .exif_transpose (image , in_place = True ) # creates a copy with rotation normalised
238+ if image is None :
239+ raise ValueError ("NoImage" )
197240 orig_ar = Fraction (* image .size )
198- logger .debug (f"Rotating image took { time .time () - start } seconds, image is now { image .size } original AR is { orig_ar } " )
241+ logger .debug (
242+ f"Rotating image took { time .time () - start } seconds, image is now { image .size } original AR is { orig_ar } "
243+ )
199244
200245 logger .debug ("Getting exif metadata from image..." )
201246 start = time .time ()
202247 exif = image .getexif ()
203248 logger .debug (f"Getting exif data took { time .time () - start } seconds" )
204249
205- size = job ["width" ], job ["height" ]
250+ size = int ( job ["width" ]), int ( job ["height" ])
206251 ratio = Fraction (* size )
207252
208- if job [' custom_aspect_ratio' ]:
209- orig = "custom"
253+ if job [" custom_aspect_ratio" ]:
254+ orig_str = "custom"
210255 else :
211- orig = "original"
256+ orig_str = "original"
212257 if orig_ar != ratio :
213- orig += "(ish)"
214- logger .debug (f"Desired image size is { size } , aspect ratio: { ratio } ({ orig } ), converting image..." )
258+ orig_str += "(ish)"
259+ logger .debug (f"Desired image size is { size } , aspect ratio: { ratio } ({ orig_str } ), converting image..." )
215260 start = time .time ()
216261 # custom AR or not?
217- if job [' custom_aspect_ratio' ]:
218- image = ImageOps .fit (image , size )
262+ if job [" custom_aspect_ratio" ]:
263+ image = ImageOps .fit (image , size ) # type: ignore[assignment]
219264 else :
220265 image .thumbnail (size )
221266 logger .debug (f"Converting image size and AR took { time .time () - start } seconds" )
@@ -243,7 +288,7 @@ def upload_job_result(self, job_uuid: uuid.UUID, buf: "BytesIO", filename: str)
243288 logger .debug (f"Done, it took { t } seconds to upload { size } bytes, speed { round (size / t )} bytes/sec" )
244289 return r .json ()
245290
246- def get_exif (self , fname : Path ) -> dict [ str , dict [ str , str ]] :
291+ def get_exif (self , fname : Path ) -> ExifExtractionJobResult :
247292 """Return a dict with exif data as read by exifread from the file.
248293
249294 exifread returns a flat dict of key: value pairs where the key
@@ -253,7 +298,7 @@ def get_exif(self, fname: Path) -> dict[str, dict[str, str]]:
253298 """
254299 with fname .open ("rb" ) as f :
255300 tags = exifread .process_file (f , details = True )
256- grouped = {}
301+ grouped : dict [ str , dict [ str , str ]] = {}
257302 for tag , value in tags .items ():
258303 if tag in SKIP_EXIF_TAGS :
259304 logger .debug (f"Skipping exif tag { tag } " )
0 commit comments