diff --git a/k8s/manifest.yaml b/k8s/manifest.yaml index 9c27c1b..62c764c 100644 --- a/k8s/manifest.yaml +++ b/k8s/manifest.yaml @@ -30,6 +30,9 @@ spec: tags.datadoghq.com/service: "links" spec: volumes: + - name: downloads + persistentVolumeClaim: + claimName: downloads-nfs - name: data persistentVolumeClaim: claimName: links-pvc @@ -53,6 +56,9 @@ spec: mountPath: /app/data - name: cache mountPath: /app/.cache + - name: downloads + subPath: ftp + mountPath: /images env: - name: R2_CUSTOM_DOMAIN value: home-links-prod.junv.cc @@ -77,6 +83,8 @@ spec: value: "home-links-prod" - name: R2_ENDPOINT_URL value: https://d39b5aca439164602c01f7af2a58d4bf.r2.cloudflarestorage.com + - name: IMAGES_FOLDER + value: "/images" - name: R2_SECRET_ACCESS_KEY valueFrom: secretKeyRef: diff --git a/links/api_urls.py b/links/api_urls.py index 9558abf..872621f 100644 --- a/links/api_urls.py +++ b/links/api_urls.py @@ -13,4 +13,6 @@ router.register('music', api_views.MusicViewSet, basename='api-music') # The API URLs are determined automatically by the router urlpatterns = [ path('', include(router.urls)), + path('images/', include('links.image_urls')), + ] diff --git a/links/image_api.py b/links/image_api.py new file mode 100644 index 0000000..b1c6350 --- /dev/null +++ b/links/image_api.py @@ -0,0 +1,149 @@ +import os +import random +from pathlib import Path +from typing import Optional, Tuple, Literal +from PIL import Image +import io +from django.http import HttpResponse, HttpResponseNotFound +from django.views.decorators.http import require_http_methods +from django.core.cache import cache +from django.conf import settings +from functools import lru_cache +from concurrent.futures import ThreadPoolExecutor + +FitMode = Literal['clip', 'crop', 'fill', 'scale'] + +# Get image folder from environment variable or use a default +IMAGES_FOLDER = os.getenv('IMAGES_FOLDER', '/Users/junv/Downloads') +CACHE_TIMEOUT = 600 # 1 hour +SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} +image_paths_cache_key = 'random_image_paths' +executor = ThreadPoolExecutor(max_workers=4) + +@lru_cache(maxsize=1) +def get_image_paths() -> list: + """Cache the list of image paths in memory.""" + if not IMAGES_FOLDER: + return [] + + image_paths = cache.get(image_paths_cache_key) + if image_paths is None: + image_paths = [] + for ext in SUPPORTED_FORMATS: + image_paths.extend(list(Path(IMAGES_FOLDER).rglob(f'*{ext}'))) + cache.set(image_paths_cache_key, image_paths, CACHE_TIMEOUT) + + return image_paths + +def resize_image(image: Image.Image, width: Optional[int], height: Optional[int], fit: FitMode = 'scale') -> Image.Image: + """Resize image according to the specified fit mode. + + Args: + image: Source image + width: Target width + height: Target height + fit: Resize fit mode: + - 'clip': Resize maintaining aspect ratio, clip excess parts + - 'crop': Center crop to exact dimensions + - 'fill': Stretch to exact dimensions + - 'scale': Scale maintaining aspect ratio (default) + """ + if not width and not height: + return image + + orig_width, orig_height = image.size + + if fit == 'fill': + # Stretch to exact dimensions + if width and height: + return image.resize((width, height), Image.Resampling.LANCZOS) + + elif fit == 'crop': + # Center crop to exact dimensions + if width and height: + # First scale to cover target dimensions + ratio = max(width / orig_width, height / orig_height) + new_width = int(orig_width * ratio) + new_height = int(orig_height * ratio) + resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Then crop to center + left = (new_width - width) // 2 + top = (new_height - height) // 2 + right = left + width + bottom = top + height + return resized.crop((left, top, right, bottom)) + + elif fit == 'clip': + # Scale maintaining aspect ratio, then clip + if width and height: + ratio = min(width / orig_width, height / orig_height) + new_width = int(orig_width * ratio) + new_height = int(orig_height * ratio) + return image.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Default: 'scale' - Maintain aspect ratio + if width and height: + ratio = min(width / orig_width, height / orig_height) + new_width = int(orig_width * ratio) + new_height = int(orig_height * ratio) + return image.resize((new_width, new_height), Image.Resampling.LANCZOS) + elif width: + ratio = width / orig_width + return image.resize((width, int(orig_height * ratio)), Image.Resampling.LANCZOS) + else: + ratio = height / orig_height + return image.resize((int(orig_width * ratio), height), Image.Resampling.LANCZOS) + +def get_random_image() -> Optional[Path]: + """Get a random image path from the cache.""" + image_paths = get_image_paths() + return random.choice(image_paths) if image_paths else None + +def process_image(image_path: Path, width: Optional[int] = None, height: Optional[int] = None, + fit: FitMode = 'scale') -> Tuple[bytes, str]: + """Process image with the specified dimensions and fit mode.""" + with Image.open(image_path) as img: + # Convert RGBA to RGB if necessary + if img.mode in ('RGBA', 'LA'): + background = Image.new('RGB', img.size, (255, 255, 255)) + background.paste(img, mask=img.split()[-1]) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + if width or height: + img = resize_image(img, width, height, fit) + + # Optimize output + buffer = io.BytesIO() + img.save(buffer, format='JPEG', quality=85, optimize=True) + return buffer.getvalue(), 'image/jpeg' + +@require_http_methods(["GET"]) +def random_image(request, width: Optional[int] = None, height: Optional[int] = None): + """API endpoint to serve random images.""" + if not IMAGES_FOLDER: + return HttpResponseNotFound("Images folder not configured") + + image_path = get_random_image() + if not image_path: + return HttpResponseNotFound("No images available") + + # Get fit mode from query parameters, default to 'scale' + fit = request.GET.get('fit', 'scale') + if fit not in ('clip', 'crop', 'fill', 'scale'): + return HttpResponseNotFound("Invalid fit mode. Supported modes: clip, crop, fill, scale") + + try: + # Use thread pool for image processing + image_data, content_type = executor.submit( + process_image, image_path, width, height, fit + ).result() + + response = HttpResponse(image_data, content_type=content_type) + response['Cache-Control'] = f'public, max-age={CACHE_TIMEOUT}' + return response + + except Exception as e: + return HttpResponseNotFound(f"Error processing image: {str(e)}") diff --git a/links/image_urls.py b/links/image_urls.py new file mode 100644 index 0000000..fc60cef --- /dev/null +++ b/links/image_urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from . import image_api + +urlpatterns = [ + path('random/', image_api.random_image, name='random-image'), + path('random///', image_api.random_image, name='random-image-sized'), +] diff --git a/links/mini_apps_views.py b/links/mini_apps_views.py index 11840c1..97f8ab0 100644 --- a/links/mini_apps_views.py +++ b/links/mini_apps_views.py @@ -14,7 +14,7 @@ class MiniAppsListView(TemplateView): 'name': 'Image Gallery', 'description': 'A beautiful fullscreen image slideshow application with smooth transitions and auto-play functionality.', 'url': 'mini-apps-image-gallery', - 'thumbnail': 'https://picsum.photos/seed/picsum/400/300?fit=crop', + 'thumbnail': 'https://plus.unsplash.com/premium_photo-1748027749836-b2755867ee62?w=400&h=300&fit=crop', 'icon': 'fas fa-images', 'color': '#3498db' }, diff --git a/links/templates/links/mini_apps/image_gallery.html b/links/templates/links/mini_apps/image_gallery.html index be18e43..58132f6 100644 --- a/links/templates/links/mini_apps/image_gallery.html +++ b/links/templates/links/mini_apps/image_gallery.html @@ -381,6 +381,15 @@ +
+
{% trans "Image Source" %}
+ +
+
{% trans "Actions" %}