Make media URLs stable and cacheable, add real image thumbnails

Signed media URLs embedded the current second (TimestampSigner), so every
API response re-minted every raw/thumbnail/staged URL and the browser
re-downloaded each file on every poll or navigation. Responses also carried
no cache headers at all.

- sign with a plain Signer plus a bucket-quantized exp (7d TTL, 24h bucket),
  so a URL is byte-identical across responses and rotates once a day; legacy
  TimestampSigner URLs stay accepted for one release
- add a v=<md5> version parameter to library media URLs so replacing a file
  under the same J-ID (the optimize flow) busts caches exactly when needed
- serve_file now sends ETag/Last-Modified and a private Cache-Control and
  answers conditional requests with 304; library media gets max-age 6d +
  immutable, staged/similarity files 1h
- build cached 480px JPEG thumbnails for images (Pillow, keyed by MD5 under
  MEDIA_ROOT/thumbs) instead of serving full-size originals through the
  thumbnail endpoint; the library grid uses thumbnail_url for images too
This commit is contained in:
2026-09-23 18:13:52 -05:00
parent ecac4cb8b4
commit 37085c5dac
9 changed files with 357 additions and 74 deletions
+28 -14
View File
@@ -5,7 +5,6 @@ from pathlib import Path
from urllib.parse import urlparse
from django.conf import settings
from django.core import signing
from django.db.models import Min, Q
from django.http import Http404, StreamingHttpResponse
from django.shortcuts import get_object_or_404
@@ -31,6 +30,7 @@ from .serializers import (
MatchTaskSerializer,
MediaItemSerializer,
)
from .signing_urls import load_payload
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
MD5_RE = re.compile(r"[0-9a-fA-F]{32}")
@@ -132,11 +132,8 @@ class MediaItemViewSet(
signature = request.query_params.get("sig")
if not signature:
return None
try:
payload = signing.loads(
signature, salt=services.MEDIA_FILE_SALT, max_age=86400
)
except signing.BadSignature:
payload = load_payload(signature, services.MEDIA_FILE_SALT)
if payload is None:
return None
if payload.get("action") != action_name:
return None
@@ -158,7 +155,11 @@ class MediaItemViewSet(
status=status.HTTP_404_NOT_FOUND,
)
return services.serve_file(
request, location.path, download=request.query_params.get("download") == "1"
request,
location.path,
download=request.query_params.get("download") == "1",
max_age=services.MEDIA_CACHE_SECONDS,
immutable=True,
)
@action(detail=True, methods=["get"], throttle_classes=[])
@@ -173,13 +174,26 @@ class MediaItemViewSet(
path = Path(location.path)
if path.suffix.lower() in services.VIDEO_EXTENSIONS:
thumbnail = services.generate_video_thumbnail(item.md5, path)
if thumbnail is None:
return Response(
{"detail": "Thumbnail unavailable."},
status=status.HTTP_404_NOT_FOUND,
)
return services.serve_file(request, thumbnail)
return services.serve_file(request, path)
else:
thumbnail = services.generate_image_thumbnail(item.md5, path)
if thumbnail is not None:
return services.serve_file(
request,
thumbnail,
max_age=services.MEDIA_CACHE_SECONDS,
immutable=True,
)
if path.suffix.lower() in services.VIDEO_EXTENSIONS:
return Response(
{"detail": "Thumbnail unavailable."},
status=status.HTTP_404_NOT_FOUND,
)
return services.serve_file(
request,
path,
max_age=services.MEDIA_CACHE_SECONDS,
immutable=True,
)
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
def lookup(self, request):