From 5d142034ae32775dd38c3ad4ba7b79ae8a8f521c Mon Sep 17 00:00:00 2001 From: Kumi Date: Fri, 27 Oct 2023 20:03:51 +0200 Subject: [PATCH] A bunch of fixes and improvements --- src/pix360core/classes/stitching.py | 6 +- src/pix360core/models/content.py | 18 +++- src/pix360core/static/js/worker.js | 138 ++++++++++++++++++---------- src/pix360core/urls.py | 4 +- src/pix360core/views.py | 48 +++++++++- src/pix360core/worker.py | 46 +++++----- 6 files changed, 182 insertions(+), 78 deletions(-) diff --git a/src/pix360core/classes/stitching.py b/src/pix360core/classes/stitching.py index ae889ee..7ff1fc6 100644 --- a/src/pix360core/classes/stitching.py +++ b/src/pix360core/classes/stitching.py @@ -145,7 +145,7 @@ class BlenderStitcher(BaseStitcher): raise StitchingError(f"cube2sphere stitching failed for conversion {files[0].conversion.id}") with (Path(tempdir) / "out0001.png").open("rb") as f: - result = File.objects.create(conversion=files[0].conversion, file=ContentFile(f.read(), name="result.png")) + result = File.objects.create(conversion=files[0].conversion, file=ContentFile(f.read(), name="result.png"), mime_type="image/png") return result @@ -243,7 +243,7 @@ class PILStitcher(BaseStitcher): PIL.Image.frombytes("RGB", (t_width, t_height), bytes(raw)).save(bio, "PNG") bio.seek(0) - result = File.objects.create(conversion=files[0].conversion, file=ContentFile(output, name="result.png")) + result = File.objects.create(conversion=files[0].conversion, file=ContentFile(output, name="result.png", mime_type="image/png")) return result @@ -292,7 +292,7 @@ class PILStitcher(BaseStitcher): result.save(bio, "PNG") bio.seek(0) - result_file = File.objects.create(conversion=files[0][0].conversion, file=ContentFile(bio.read(), name="result.png")) + result_file = File.objects.create(conversion=files[0][0].conversion, file=ContentFile(bio.read(), name="result.png"), mime_type="image/png") return result_file diff --git a/src/pix360core/models/content.py b/src/pix360core/models/content.py index 6903996..dcd9314 100644 --- a/src/pix360core/models/content.py +++ b/src/pix360core/models/content.py @@ -1,6 +1,7 @@ from django.db import models from django.contrib.auth import get_user_model +import mimetypes import uuid def file_upload_path(instance, filename) -> str: @@ -46,6 +47,7 @@ class ConversionStatus(models.IntegerChoices): PROCESSING = 1 DONE = 2 FAILED = -1 + DISMISSED = -2 DOWNLOADING = 10 STITCHING = 11 @@ -82,4 +84,18 @@ class Conversion(models.Model): Raises: File.DoesNotExist: If no result file exists """ - return File.objects.get(conversion=self, is_result=True) \ No newline at end of file + return File.objects.get(conversion=self, is_result=True) + + def get_result_filename(self) -> str: + """Get the final filename for the result file + + Returns: + str: Filename for the result file + + Raises: + File.DoesNotExist: If no result file exists + """ + + basename = "".join([c for c in self.title if c.isalnum() or c in "._- "]) + extension = mimetypes.guess_extension(self.result.mime_type) + return f"{basename}{extension}" \ No newline at end of file diff --git a/src/pix360core/static/js/worker.js b/src/pix360core/static/js/worker.js index 9196419..1f45266 100644 --- a/src/pix360core/static/js/worker.js +++ b/src/pix360core/static/js/worker.js @@ -2,6 +2,8 @@ $("#options").hide(); $body = $("body"); +var intervals = {}; + function toggleOptions() { $("#options").toggle(); } @@ -22,6 +24,51 @@ function deletecard(jobid) { } } +function checkServerForFile(jobid, title, interval) { + $.ajax({ + type: "GET", + cache: false, + url: "/status/" + jobid, + statusCode: { + 403: function () { + Notification.requestPermission(function (permission) { + if (permission === "granted") { + var notification = new Notification("PIX360", { + body: "Your session has expired. Please log in again.", + }); + } + }); + window.location.href = "/"; + }, + 404: function () { + clearInterval(intervals[jobid]); + failcard(jobid, title); + return; + }, + 200: function (data, tstatus, xhr) { + if (data.status == "completed") { + clearInterval(intervals[jobid]); + finishcard( + jobid, + title, + data.content_type == "video/mp4" + ); + return; + } else if (data.status == "failed") { + clearInterval(intervals[jobid]); + failcard(jobid, title); + return; + } + }, + 500: function () { + clearInterval(intervals[jobid]); + failcard(jobid, title); + return; + }, + }, + }); +} + function addcard(jobid, title) { var text = '
'; $("#" + jobid).html(text); @@ -61,24 +122,26 @@ function finishcard(jobid, title, video) { var text = '
Final ' +
     (video ?
' + title + - '
'; + '\');" class="btn btn-danger">Hide '; $("#" + jobid).html(text); var counter = 0; var interval = setInterval(function () { var image = document.getElementById(jobid + "-thumb"); - image.src = "/getjob/" + jobid + "-thumb?rand=" + Math.random(); + image.src = "/download/" + jobid + "-thumb?rand=" + Math.random(); if (++counter === 10) { window.clearInterval(interval); } @@ -95,57 +158,34 @@ $("#theform").submit(function (event) { success: function (msg) { var title = $("#title").val() ? $("#title").val() : "No title"; var interval = setInterval(checkServerForFile, 3000, msg.id, title); - window.panaxworking = false; + intervals[msg.id] = interval; addcard(msg.id, title); - - function checkServerForFile(jobid, title) { - if (!window.panaxworking) { - window.panaxworking = true; - $.ajax({ - type: "GET", - cache: false, - url: "/status/" + jobid, - statusCode: { - 403: function () { - window.location.href = "/"; - }, - 404: function () { - clearInterval(interval); - failcard(jobid, title); - return; - }, - 200: function (data, tstatus, xhr) { - if (data.status == "finished") { - clearInterval(interval); - finishcard( - jobid, - title, - data.content_type == "video/mp4" - ); - return; - } else if (data.status == "failed") { - clearInterval(interval); - failcard(jobid, title); - return; - } - }, - 500: function () { - clearInterval(interval); - failcard(jobid, title); - return; - }, - }, - }); - window.panaxworking = false; - } - } }, }); } }); +function initialize() { + $.ajax({ + type: "GET", + url: "/list", + success: function (msg) { + for (var i = 0; i < msg["conversions"].length; i++) { + var job = msg["conversions"][i]; + if (job.status >= 0) { + var title = job.title ? job.title : "No title"; + addcard(job.id, title); + var interval = setInterval(checkServerForFile, 3000, job.id, title); + intervals[job.id] = interval; + } + } + } + }); +} + $(document).ready(function () { if (Notification.permission !== "granted") { Notification.requestPermission(); - } + }; + initialize(); }); diff --git a/src/pix360core/urls.py b/src/pix360core/urls.py index be9ba2a..d6f5f87 100644 --- a/src/pix360core/urls.py +++ b/src/pix360core/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from .views import ConverterView, StartConversionView, ConversionStatusView, ConversionLogView, ConversionListView, ConversionDeleteView, ConversionResultView +from .views import ConverterView, StartConversionView, ConversionStatusView, ConversionLogView, ConversionListView, ConversionDeleteView, ConversionResultView, ConversionRetryView, ConversionDownloadView urlpatterns = [ path('', ConverterView.as_view(), name='converter'), @@ -10,4 +10,6 @@ urlpatterns = [ path('list', ConversionListView.as_view(), name='conversion_list'), path('delete/', ConversionDeleteView.as_view(), name='conversion_delete'), path('result/', ConversionResultView.as_view(), name='conversion_result'), + path('retry/', ConversionRetryView.as_view(), name='conversion_retry'), + path('download/', ConversionDownloadView.as_view(), name='conversion_download'), ] \ No newline at end of file diff --git a/src/pix360core/views.py b/src/pix360core/views.py index 18a4bbb..0ff68eb 100644 --- a/src/pix360core/views.py +++ b/src/pix360core/views.py @@ -47,7 +47,7 @@ class ConversionStatusView(LoginRequiredMixin, View): if conversion.status == ConversionStatus.DONE: response['status'] = "completed" - response['result'] = conversion.result.file.path + response['result'] = conversion.result.file.url response['content_type'] = conversion.result.mime_type elif conversion.status == ConversionStatus.FAILED: response['status'] = "failed" @@ -127,7 +127,49 @@ class ConversionDeleteView(LoginRequiredMixin, View): 'error': 'Conversion not found' }, status=404) - conversion.user = None + conversion.status = ConversionStatus.DISMISSED conversion.save() - return JsonResponse({}) \ No newline at end of file + return JsonResponse({}) + +class ConversionRetryView(LoginRequiredMixin, View): + """View for retrying a conversion + """ + def get(self, request, *args, **kwargs): + """Handle the POST request + """ + conversion = Conversion.objects.filter(id=kwargs['id']).first() + if not conversion or not (conversion.user == request.user): + return JsonResponse({ + 'error': 'Conversion not found' + }, status=404) + + new_conversion = Conversion.objects.create(url=conversion.url, title=conversion.title, user=request.user) + + return JsonResponse({ + 'id': new_conversion.id + }) + +class ConversionDownloadView(LoginRequiredMixin, View): + """View for downloading a conversion + """ + def get(self, request, *args, **kwargs): + """Handle the GET request + """ + conversion = Conversion.objects.filter(id=kwargs['id']).first() + if not conversion or not (conversion.user == request.user): + return JsonResponse({ + 'error': 'Conversion not found' + }, status=404) + + file = conversion.result + if not file: + return JsonResponse({ + 'error': 'Conversion not done' + }, status=404) + + content = file.file.read() + + response = HttpResponse(content, content_type=file.mime_type) + response['Content-Disposition'] = f'attachment; filename="{conversion.get_result_filename()}"' + return response \ No newline at end of file diff --git a/src/pix360core/worker.py b/src/pix360core/worker.py index 99f210c..67087fb 100644 --- a/src/pix360core/worker.py +++ b/src/pix360core/worker.py @@ -3,6 +3,7 @@ from .models import Conversion, File, ConversionStatus from .classes import ConversionError from django.conf import settings +from django.db import transaction import multiprocessing import logging @@ -65,30 +66,33 @@ class Worker(multiprocessing.Process): """ while True: try: - conversion = Conversion.objects.filter(status=ConversionStatus.PENDING).first() + with transaction.atomic(): + conversion = Conversion.objects.select_for_update().filter(status=ConversionStatus.PENDING).first() - if conversion: - conversion.status = ConversionStatus.PROCESSING - conversion.save() - self.logger.info(f"Processing conversion {conversion.id}") - - try: - result = self.process_conversion(conversion) - result.is_result = True - result.save() - conversion.status = ConversionStatus.DONE + if conversion: + conversion.status = ConversionStatus.PROCESSING conversion.save() - self.logger.info(f"Conversion {conversion.id} done") - except Exception as e: - conversion.status = ConversionStatus.FAILED - conversion.log = traceback.format_exc() - conversion.save() - self.logger.error(f"Conversion {conversion.id} failed: {e}") - self.logger.debug(traceback.format_exc()) - else: - self.logger.debug("No conversion to process") - time.sleep(1) + else: + self.logger.debug("No conversion to process") + time.sleep(1) + continue + + self.logger.info(f"Processing conversion {conversion.id}") + + try: + result = self.process_conversion(conversion) + result.is_result = True + result.save() + conversion.status = ConversionStatus.DONE + conversion.save() + self.logger.info(f"Conversion {conversion.id} done") + except Exception as e: + conversion.status = ConversionStatus.FAILED + conversion.log = traceback.format_exc() + conversion.save() + self.logger.error(f"Conversion {conversion.id} failed: {e}") + self.logger.debug(traceback.format_exc()) except Exception as e: self.logger.error(f"Worker error: {e}") \ No newline at end of file