diff --git a/links/file_urls.py b/links/file_urls.py index 676fa7c..957bd60 100644 --- a/links/file_urls.py +++ b/links/file_urls.py @@ -13,4 +13,5 @@ urlpatterns = [ path('/delete/', file_views.FileDeleteView.as_view(), name='file-delete'), path('/toggle-public/', file_views.FileTogglePublicView.as_view(), name='file-toggle-public'), path('/set-expiry/', file_views.FileSetExpiryView.as_view(), name='file-set-expiry'), + path('/thumb/', file_views.FileThumbnailView.as_view(), name='file-thumbnail'), ] diff --git a/links/file_views.py b/links/file_views.py index c9de923..bc2a361 100644 --- a/links/file_views.py +++ b/links/file_views.py @@ -3,8 +3,10 @@ import mimetypes import os import secrets import logging +import threading from pathlib import Path +from PIL import Image from django.conf import settings from django.db.models import F from django.http import FileResponse, JsonResponse, Http404, HttpResponse @@ -161,6 +163,83 @@ def _stream_file(file_path, mime_type, disposition, range_header=None): # ── UI Views ────────────────────────────────────────────────────────────────── +THUMB_MAX_DIM = 480 # longest edge of generated thumbnails (JPEG) +_THUMB_LOCK = {} # per-record lock to avoid duplicate generation + + +def _generate_thumbnail(record): + """Generate (and cache on disk) a thumbnail for an image or video FileUpload. + + - Images: Pillow resize, longest edge capped at THUMB_MAX_DIM, JPEG quality 82. + - Videos: ffmpeg extracts the first frame, then Pillow downscales the same way. + Returns the thumbnail path on success, or None (no thumbnail possible / failed). + """ + if not (record.is_image or record.mime_type.startswith('video/')): + return None + if not os.path.exists(record.file_path): + return None + + thumb = record.thumb_path + if os.path.exists(thumb): + return thumb + + lock = _THUMB_LOCK.setdefault(str(record.pk), threading.Lock()) + with lock: + # Re-check after acquiring the lock (another request may have generated it) + if os.path.exists(thumb): + return thumb + tmp = f'{thumb}.part' + try: + Path(thumb).parent.mkdir(parents=True, exist_ok=True) + if record.is_image: + with Image.open(record.file_path) as im: + im = im.convert('RGB') + im.thumbnail((THUMB_MAX_DIM, THUMB_MAX_DIM)) + im.save(tmp, 'JPEG', quality=82, optimize=True) + else: # video → ffmpeg first frame + import subprocess + result = subprocess.run( + ['ffmpeg', '-y', '-loglevel', 'error', + '-i', record.file_path, + '-frames:v', '1', '-vf', f"scale='min({THUMB_MAX_DIM},iw)':-2", + '-f', 'image2', tmp], + capture_output=True, timeout=60, + ) + if result.returncode != 0 or not os.path.exists(tmp) or os.path.getsize(tmp) == 0: + logger.warning('ffmpeg thumbnail failed for %s: %s', record.pk, + result.stderr.decode(errors='ignore')[:200]) + _cleanup_partial(tmp) + return None + # Downscale huge first frames to the same cap + with Image.open(tmp) as im: + im.thumbnail((THUMB_MAX_DIM, THUMB_MAX_DIM)) + im.save(tmp, 'JPEG', quality=82, optimize=True) + os.replace(tmp, thumb) + return thumb + except Exception as exc: + logger.warning('thumbnail generation failed for %s: %s', record.pk, exc) + try: + _cleanup_partial(tmp) + except NameError: + pass + return None + + +class FileThumbnailView(View): + """Serve a generated thumbnail for image/video FileUploads. + + GET /ui/files/{uuid}/thumb/ → 200 image/jpeg (generated & cached on first hit) + Non-media types → 404 (the UI falls back to a type icon). + """ + + def get(self, request, pk): + record = get_object_or_404(FileUpload, pk=pk) + thumb = _generate_thumbnail(record) + if not thumb or not os.path.exists(thumb): + raise Http404('No thumbnail available for this file') + return FileResponse(open(thumb, 'rb'), content_type='image/jpeg') + + class FileListView(View): def get(self, request): files = FileUpload.objects.all() @@ -197,6 +276,7 @@ class FileUploadView(View): 'created_at': record.created_at.isoformat(), 'download_url': record.download_url, 'is_image': record.is_image, + 'thumbnail_url': record.thumbnail_url, }) if is_ajax: status_code = 200 if results else 500 diff --git a/links/models.py b/links/models.py index dffde38..763ba69 100644 --- a/links/models.py +++ b/links/models.py @@ -467,6 +467,19 @@ class FileUpload(models.Model): safe_name = re.sub(r'[^\w.\-]', '-', self.name) return f'/ui/files/{self.pk}-{safe_name}' + @property + def thumb_path(self): + """Disk path for the generated thumbnail (image resize / video first-frame).""" + folder = os.path.join(settings.FILE_UPLOADS_FOLDER, 'thumbs') + return os.path.join(folder, f'{self.stored_name}.jpg') + + @property + def thumbnail_url(self): + """URL of the server-generated thumbnail; empty for non-media types.""" + if not (self.is_image or self.mime_type.startswith('video/')): + return '' + return f'/ui/files/{self.pk}/thumb/' + @property def public_url(self): import re as _re diff --git a/links/serializers.py b/links/serializers.py index db5e5a5..1fb9afb 100644 --- a/links/serializers.py +++ b/links/serializers.py @@ -129,6 +129,7 @@ class FileUploadSerializer(serializers.ModelSerializer): formatted_size = serializers.SerializerMethodField() public_url = serializers.SerializerMethodField() is_expired = serializers.SerializerMethodField() + thumbnail_url = serializers.SerializerMethodField() class Meta: model = FileUpload @@ -137,11 +138,13 @@ class FileUploadSerializer(serializers.ModelSerializer): 'is_public', 'public_token', 'public_url', 'expires_at', 'is_expired', 'download_count', 'created_at', 'updated_at', + 'thumbnail_url', ] read_only_fields = [ 'id', 'mime_type', 'size', 'formatted_size', 'public_token', 'public_url', 'is_expired', 'download_count', 'created_at', 'updated_at', + 'thumbnail_url', ] def get_formatted_size(self, obj): @@ -153,6 +156,9 @@ class FileUploadSerializer(serializers.ModelSerializer): def get_is_expired(self, obj): return obj.is_expired + def get_thumbnail_url(self, obj): + return obj.thumbnail_url + class BookmarkSerializer(serializers.ModelSerializer): """Full bookmark for read / single-create / update.""" diff --git a/links/templates/links/files/list.html b/links/templates/links/files/list.html index 00e2942..c336b49 100644 --- a/links/templates/links/files/list.html +++ b/links/templates/links/files/list.html @@ -212,11 +212,11 @@ .grid-thumb { border-radius: 13px; } } - /* ---------- Carousel view (iOS cover-flow style) ---------- */ - .file-carousel { position: relative; } + /* ---------- Carousel view (3D cover-flow style) ---------- */ + .file-carousel { position: relative; perspective: 1400px; } .carousel-track { display: flex; gap: 1.1rem; overflow-x: auto; - padding: 0.9rem 1.8rem 1.4rem; margin-bottom: -1.4rem; + padding: 1.6rem 1.8rem 1.8rem; margin-bottom: -1.4rem; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; scrollbar-width: none; } @@ -227,9 +227,12 @@ background: #fff; border-radius: var(--apple-radius-lg); border: 1px solid rgba(0, 0, 0, 0.05); box-shadow: var(--apple-shadow-card); padding: 0.8rem; cursor: pointer; text-align: left; - transition: transform 0.3s var(--apple-ease), box-shadow 0.3s var(--apple-ease); + transform-style: preserve-3d; + transition: transform 0.25s var(--apple-ease), opacity 0.25s ease, box-shadow 0.25s ease; + will-change: transform; } - .carousel-card:hover { transform: translateY(-4px) scale(1.02); box-shadow: var(--apple-shadow-lift); } + .carousel-card:hover { box-shadow: var(--apple-shadow-lift); } + .carousel-card.active { box-shadow: 0 18px 48px rgba(0, 0, 0, 0.16); } .carousel-cover { position: relative; width: 100%; aspect-ratio: 1 / 1; border-radius: 18px; overflow: hidden; background: #f0f0f2; @@ -501,7 +504,8 @@ "public_url": "{{ file.public_url|escapejs }}", "expires_at": "{{ file.expires_at|date:'c' }}", "download_url": "{{ file.download_url|escapejs }}", - "is_image": {{ file.is_image|lower }} + "is_image": {{ file.is_image|lower }}, + "thumbnail_url": "{{ file.thumbnail_url|escapejs }}" }{% if not forloop.last %},{% endif %}{% endfor %}] @@ -616,12 +620,11 @@ :title="f.name" role="button" tabindex="0" @keydown.enter.prevent="openPreview({name:f.name, mimeType:f.mime_type, url:f.download_url})"> - +
- - -