Client-side optimization pipeline with apply-to-J-ID
Backend: - POST /api/files/J-x/optimize/ applies a browser-processed file: replaces every copy (renaming when the extension changes), recomputes MD5, size and perceptual hashes, seeds guest visibility; 400 when identical, 409 when the result matches another item, owner/staff only. Frontend (no server-side processing by design): - optimize.worker.ts + pipelines: Mediabunny/WebCodecs for video with a prefer-hardware hint and per-browser codec detection; MozJPEG/OxiPNG/ libwebp (jSquash) for images; gifuct-js+gifenc and UPNG for GIF/APNG. - OptimizeModal: per-file-type options, original vs processed previews with sizes/savings, progress bar with ETA, then Apply (overwrite). - Optimize button on the library detail for the uploader/staff.
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -296,6 +298,113 @@ class MediaItemViewSet(
|
||||
matching.unlink_item(item)
|
||||
return Response(self.get_serializer(item).data)
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[CanUpload])
|
||||
def optimize(self, request, pk=None):
|
||||
"""Replace this item's file(s) with a client-processed version.
|
||||
|
||||
The browser does the actual compression (WebCodecs/WASM); this only
|
||||
applies the result. The item keeps its J-ID while MD5, size and
|
||||
perceptual hashes are recomputed, so it may become a duplicate again.
|
||||
"""
|
||||
item = self.get_object()
|
||||
if not self._can_match(request, item):
|
||||
return Response(
|
||||
{"detail": "Only the uploader or staff can optimize this item."},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
upload = request.FILES.get("file")
|
||||
if upload is None:
|
||||
return Response(
|
||||
{"detail": "A processed file is required."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
extension = Path(upload.name).suffix.lower()
|
||||
if extension not in services.ALLOWED_EXTENSIONS:
|
||||
return Response(
|
||||
{"detail": f"Unsupported file type: {extension or 'unknown'}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
locations = list(item.locations.all())
|
||||
if not locations:
|
||||
return Response(
|
||||
{"detail": "This item has no file on disk."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
folder = Path(locations[0].path).parent
|
||||
temp_path = folder / f".optimize-{uuid.uuid4().hex}{extension}"
|
||||
try:
|
||||
with open(temp_path, "wb") as handle:
|
||||
for chunk in upload.chunks():
|
||||
handle.write(chunk)
|
||||
new_md5 = services.compute_md5(temp_path)
|
||||
new_size = temp_path.stat().st_size
|
||||
except OSError as exc:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
return Response(
|
||||
{"detail": f"Could not store the processed file: {exc}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
if new_md5 == item.md5:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
return Response(
|
||||
{"detail": "The processed file is identical to the current one."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
clash = MediaItem.objects.filter(md5=new_md5).exclude(pk=item.pk).first()
|
||||
if clash is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
return Response(
|
||||
{
|
||||
"detail": (
|
||||
f"The processed file is an exact match (MD5) for "
|
||||
f"J-{clash.id}; nothing was changed."
|
||||
)
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
new_name = f"J-{item.id}{extension}"
|
||||
try:
|
||||
for location in locations:
|
||||
target = Path(location.path).parent / new_name
|
||||
shutil.copyfile(temp_path, target)
|
||||
if str(target) != location.path:
|
||||
Path(location.path).unlink(missing_ok=True)
|
||||
location.path = str(target)
|
||||
try:
|
||||
location.rel_path = str(
|
||||
target.relative_to(settings.WATCHED_FOLDER)
|
||||
)
|
||||
except ValueError:
|
||||
location.rel_path = target.name
|
||||
location.mtime = target.stat().st_mtime
|
||||
location.save(update_fields=["path", "rel_path", "mtime"])
|
||||
except OSError as exc:
|
||||
return Response(
|
||||
{"detail": f"Could not replace the file(s): {exc}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
hashes = (
|
||||
services.compute_visual_hashes(locations[0].path)
|
||||
if extension in services.IMAGE_EXTENSIONS
|
||||
else {}
|
||||
)
|
||||
item.md5 = new_md5
|
||||
item.size = new_size
|
||||
for field in services.HASH_FIELDS:
|
||||
setattr(item, field, hashes.get(field, ""))
|
||||
item.save(
|
||||
update_fields=["md5", "size", *services.HASH_FIELDS, "updated_at"]
|
||||
)
|
||||
|
||||
item = self.get_queryset().get(pk=item.pk)
|
||||
return Response(self.get_serializer(item).data)
|
||||
|
||||
|
||||
class DownloadTaskViewSet(
|
||||
mixins.ListModelMixin,
|
||||
|
||||
Reference in New Issue
Block a user